diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000000..f5f1b6a6113c --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,5 @@ +{ + "disabledMcpjsonServers": [ + "doc-bot" + ] +} diff --git a/data-store/data-store-api/build.gradle b/data-store/data-store-api/build.gradle index d42f46834c2c..935060112725 100644 --- a/data-store/data-store-api/build.gradle +++ b/data-store/data-store-api/build.gradle @@ -33,6 +33,7 @@ dependencies { implementation KotlinX.coroutines.core implementation AndroidX.appCompat + implementation AndroidX.room.ktx coreLibraryDesugaring Android.tools.desugarJdkLibs } diff --git a/data-store/data-store-api/src/main/java/com/duckduckgo/data/store/api/DatabaseProvider.kt b/data-store/data-store-api/src/main/java/com/duckduckgo/data/store/api/DatabaseProvider.kt new file mode 100644 index 000000000000..3356903b87eb --- /dev/null +++ b/data-store/data-store-api/src/main/java/com/duckduckgo/data/store/api/DatabaseProvider.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.data.store.api + +import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteOpenHelper + +/** + * Configuration options for building a Room database + * @property openHelperFactory Custom SQLite open helper factory (e.g., for encryption). Null by default. + * @property enableMultiInstanceInvalidation Whether to enable multi-instance invalidation. False by default. + * @property journalMode The journal mode to use for the database. Null by default. + * @property callbacks List of database callbacks to be added. Empty list by default. + * @property fallbackToDestructiveMigration If true, the database will be recreated if a migration is not found. False by default. + * @property fallbackToDestructiveMigrationFromVersion List of versions to fallback to destructive migration from. Empty list by default. + * @property migrations List of migrations to apply to the database. Empty list by default. + */ +data class RoomDatabaseConfig( + val openHelperFactory: SupportSQLiteOpenHelper.Factory? = null, + val enableMultiInstanceInvalidation: Boolean = false, + val journalMode: RoomDatabase.JournalMode? = null, + val callbacks: List = emptyList(), + val fallbackToDestructiveMigration: Boolean = false, + val fallbackToDestructiveMigrationFromVersion: List = emptyList(), + val migrations: List = emptyList(), + val executor: DatabaseExecutor = DatabaseExecutor.Default, +) + +sealed class DatabaseExecutor { + + /** + * Custom executor configuration for Room database operations. + * @property transactionPoolSize The size of the thread pool for transaction operations. + * @property queryPoolSize The size of the thread pool for query operations. + * @property transactionQueueSize Optional size of the queue for transaction operations. If not set, 2 * [transactionPoolSize] will be used. + * @property queryQueueSize Optional size of the queue for query operations. If not set, 2 * [queryPoolSize] will be used. + */ + class Custom( + val transactionPoolSize: Int, + val queryPoolSize: Int, + val transactionQueueSize: Int = 2 * transactionPoolSize, + val queryQueueSize: Int = 2 * queryPoolSize, + ) : DatabaseExecutor() + + data object Default : DatabaseExecutor() +} + +interface DatabaseProvider { + /** + * @param klass - The abstract class which is annotated with Database and extends RoomDatabase. + * @param name - The name of the database file. + * @param T - The type of the database class. + * @param config - Optional configuration for the database build process. Empty by default. + * @return T: RoomDatabase - A database + */ + fun buildRoomDatabase( + klass: Class, + name: String, + config: RoomDatabaseConfig = RoomDatabaseConfig(), + ): T +} diff --git a/data-store/data-store-impl/build.gradle b/data-store/data-store-impl/build.gradle index 0bd68816911d..9671a912ac9f 100644 --- a/data-store/data-store-impl/build.gradle +++ b/data-store/data-store-impl/build.gradle @@ -30,6 +30,7 @@ dependencies { anvil project(':anvil-compiler') implementation project(':anvil-annotations') implementation project(':di') + implementation project(':feature-toggles-api') ksp AndroidX.room.compiler implementation KotlinX.coroutines.android @@ -45,8 +46,15 @@ dependencies { // Security crypto implementation AndroidX.security.crypto + // Room + implementation AndroidX.room.runtime + implementation AndroidX.room.rxJava2 + implementation AndroidX.room.ktx + ksp AndroidX.room.compiler + testImplementation "org.mockito.kotlin:mockito-kotlin:_" testImplementation project(':common-test') + testImplementation project(':feature-toggles-test') testImplementation Testing.junit4 testImplementation AndroidX.test.ext.junit testImplementation CashApp.turbine diff --git a/data-store/data-store-impl/src/main/java/com/duckduckgo/data/store/impl/DatabaseExecutorProvider.kt b/data-store/data-store-impl/src/main/java/com/duckduckgo/data/store/impl/DatabaseExecutorProvider.kt new file mode 100644 index 000000000000..36b2a81df8aa --- /dev/null +++ b/data-store/data-store-impl/src/main/java/com/duckduckgo/data/store/impl/DatabaseExecutorProvider.kt @@ -0,0 +1,107 @@ +package com.duckduckgo.data.store.impl + +import com.duckduckgo.data.store.api.DatabaseExecutor +import com.duckduckgo.data.store.api.DatabaseExecutor.Custom +import com.duckduckgo.data.store.api.DatabaseExecutor.Default +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +interface DatabaseExecutorProvider { + fun createQueryExecutor(executor: DatabaseExecutor): Executor? + fun createTransactionExecutor(executor: DatabaseExecutor): Executor? +} + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class) +class RealDatabaseExecutorProvider @Inject constructor( + databaseProviderFeature: DatabaseProviderFeature, +) : DatabaseExecutorProvider { + + private val defaultPoolSize = 6 + private val defaultExecutor = + ThreadPoolExecutor( + defaultPoolSize, + defaultPoolSize, + 0L, + TimeUnit.SECONDS, + ArrayBlockingQueue(defaultPoolSize * 2), + ThreadPoolExecutor.CallerRunsPolicy(), + ) + + private val isFeatureFlagEnabled = databaseProviderFeature.self().isEnabled() + + override fun createQueryExecutor(executor: DatabaseExecutor): Executor? { + return when (executor) { + is Custom -> { + if (isFeatureFlagEnabled) { + createCustomExecutor(executor.queryPoolSize, executor.queryQueueSize) + } else { + createLegacyQueryExecutor() + } + } + Default -> { + if (isFeatureFlagEnabled) { + defaultExecutor + } else { + null + } + } + } + } + + override fun createTransactionExecutor(executor: DatabaseExecutor): Executor? { + return when (executor) { + is Custom -> { + if (isFeatureFlagEnabled) { + createCustomExecutor(executor.transactionPoolSize, executor.transactionQueueSize) + } else { + createLegacyTransactionExecutor() + } + } + Default -> { + if (isFeatureFlagEnabled) { + defaultExecutor + } else { + null + } + } + } + } + + private fun createCustomExecutor( + poolSize: Int, + queueSize: Int, + ): Executor { + val queue = object : ArrayBlockingQueue(queueSize) { + override fun add(element: Runnable): Boolean { + return super.add(element) + } + } + + return ThreadPoolExecutor( + poolSize, + poolSize, + 60L, + TimeUnit.SECONDS, + queue, + ThreadPoolExecutor.CallerRunsPolicy(), + ).apply { + allowCoreThreadTimeOut(true) + } + } + + private fun createLegacyQueryExecutor(): Executor { + return Executors.newFixedThreadPool(4) + } + + private fun createLegacyTransactionExecutor(): Executor { + return Executors.newSingleThreadExecutor() + } +} diff --git a/data-store/data-store-impl/src/main/java/com/duckduckgo/data/store/impl/DatabaseProviderFeature.kt b/data-store/data-store-impl/src/main/java/com/duckduckgo/data/store/impl/DatabaseProviderFeature.kt new file mode 100644 index 000000000000..85598db41e60 --- /dev/null +++ b/data-store/data-store-impl/src/main/java/com/duckduckgo/data/store/impl/DatabaseProviderFeature.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.data.store.impl + +import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue + +@ContributesRemoteFeature( + scope = AppScope::class, + featureName = "databaseProvider", +) +interface DatabaseProviderFeature { + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun self(): Toggle +} diff --git a/data-store/data-store-impl/src/main/java/com/duckduckgo/data/store/impl/RoomDatabaseBuilderFactory.kt b/data-store/data-store-impl/src/main/java/com/duckduckgo/data/store/impl/RoomDatabaseBuilderFactory.kt new file mode 100644 index 000000000000..0e528850356a --- /dev/null +++ b/data-store/data-store-impl/src/main/java/com/duckduckgo/data/store/impl/RoomDatabaseBuilderFactory.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.data.store.impl + +import android.annotation.SuppressLint +import android.content.Context +import androidx.room.Room +import androidx.room.RoomDatabase +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +/** + * Factory for creating Room database builders to enable better testing + */ +interface RoomDatabaseBuilderFactory { + fun createBuilder( + context: Context, + klass: Class, + name: String, + ): RoomDatabase.Builder +} + +/** + * Real implementation of RoomDatabaseBuilderFactory that uses Room.databaseBuilder + */ +@ContributesBinding(AppScope::class) +class RealRoomDatabaseBuilderFactory @Inject constructor() : RoomDatabaseBuilderFactory { + @SuppressLint("DenyListedApi") + override fun createBuilder( + context: Context, + klass: Class, + name: String, + ): RoomDatabase.Builder { + return Room.databaseBuilder(context, klass, name) + } +} diff --git a/data-store/data-store-impl/src/main/java/com/duckduckgo/data/store/impl/RoomDatabaseProviderImpl.kt b/data-store/data-store-impl/src/main/java/com/duckduckgo/data/store/impl/RoomDatabaseProviderImpl.kt new file mode 100644 index 000000000000..6b58ad81660b --- /dev/null +++ b/data-store/data-store-impl/src/main/java/com/duckduckgo/data/store/impl/RoomDatabaseProviderImpl.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.data.store.impl + +import android.content.Context +import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteOpenHelper +import com.duckduckgo.data.store.api.DatabaseProvider +import com.duckduckgo.data.store.api.RoomDatabaseConfig +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.Lazy +import dagger.SingleInstanceIn +import javax.inject.Inject + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class) +class RoomDatabaseProviderImpl @Inject constructor( + private val context: Context, + private val roomDatabaseBuilderFactory: RoomDatabaseBuilderFactory, + private val lazyDatabaseExecutorProvider: Lazy, +) : DatabaseProvider { + + override fun buildRoomDatabase( + klass: Class, + name: String, + config: RoomDatabaseConfig, + ): T { + return roomDatabaseBuilderFactory.createBuilder(context, klass, name) + .apply { + applyMigrations(config.migrations) + applyFallbackToDestructiveMigration(config.fallbackToDestructiveMigration) + applyOpenHelperFactory(config.openHelperFactory) + applyMultiInstanceInvalidation(config.enableMultiInstanceInvalidation) + applyJournalMode(config.journalMode) + applyCallbacks(config.callbacks) + applyFallbackToDestructiveMigrationFromVersion(config.fallbackToDestructiveMigrationFromVersion) + applyExecutors(config.executor) + } + .build() + } + + private fun RoomDatabase.Builder<*>.applyMigrations(migrations: List) { + if (migrations.isNotEmpty()) { + addMigrations(*migrations.toTypedArray()) + } + } + + private fun RoomDatabase.Builder<*>.applyFallbackToDestructiveMigration(fallback: Boolean) { + if (fallback) { + fallbackToDestructiveMigration() + } + } + + private fun RoomDatabase.Builder<*>.applyOpenHelperFactory(factory: SupportSQLiteOpenHelper.Factory?) { + factory?.let { openHelperFactory(it) } + } + + private fun RoomDatabase.Builder<*>.applyMultiInstanceInvalidation(enable: Boolean) { + if (enable) { + enableMultiInstanceInvalidation() + } + } + + private fun RoomDatabase.Builder<*>.applyJournalMode(journalMode: RoomDatabase.JournalMode?) { + journalMode?.let { setJournalMode(it) } + } + + private fun RoomDatabase.Builder<*>.applyCallbacks(callbacks: List) { + callbacks.forEach { addCallback(it) } + } + + private fun RoomDatabase.Builder<*>.applyFallbackToDestructiveMigrationFromVersion(versions: List) { + if (versions.isNotEmpty()) { + fallbackToDestructiveMigrationFrom(*versions.toIntArray()) + } + } + + private fun RoomDatabase.Builder<*>.applyExecutors(executor: com.duckduckgo.data.store.api.DatabaseExecutor) { + val databaseExecutorProvider = lazyDatabaseExecutorProvider.get() + val queryExecutor = databaseExecutorProvider.createQueryExecutor(executor) + val transactionExecutor = databaseExecutorProvider.createTransactionExecutor(executor) + + queryExecutor?.let { setQueryExecutor(it) } + transactionExecutor?.let { setTransactionExecutor(it) } + } +} diff --git a/data-store/data-store-impl/src/test/java/com/duckduckgo/data/store/impl/RealDatabaseExecutorProviderTest.kt b/data-store/data-store-impl/src/test/java/com/duckduckgo/data/store/impl/RealDatabaseExecutorProviderTest.kt new file mode 100644 index 000000000000..69da5c8fc2b7 --- /dev/null +++ b/data-store/data-store-impl/src/test/java/com/duckduckgo/data/store/impl/RealDatabaseExecutorProviderTest.kt @@ -0,0 +1,126 @@ +package com.duckduckgo.data.store.impl + +import android.annotation.SuppressLint +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.data.store.api.DatabaseExecutor +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.Executor +import java.util.concurrent.ThreadPoolExecutor + +@SuppressLint("DenyListedApi") +@RunWith(AndroidJUnit4::class) +class RealDatabaseExecutorProviderTest { + + private val databaseProviderFeature: DatabaseProviderFeature = FakeFeatureToggleFactory.create(DatabaseProviderFeature::class.java) + private lateinit var subject: RealDatabaseExecutorProvider + + @Test + fun whenFeatureFlagEnabledAndDefaultExecutorThenSameExecutorInstanceIsReturned() = runTest { + prepareSubject(true) + + val queryExecutor1 = subject.createQueryExecutor(DatabaseExecutor.Default) + val queryExecutor2 = subject.createQueryExecutor(DatabaseExecutor.Default) + val transactionExecutor1 = subject.createTransactionExecutor(DatabaseExecutor.Default) + val transactionExecutor2 = subject.createTransactionExecutor(DatabaseExecutor.Default) + + assertNotNull(queryExecutor1) + assertNotNull(transactionExecutor1) + assertSame(queryExecutor1, queryExecutor2) + assertSame(transactionExecutor1, transactionExecutor2) + assertSame(queryExecutor1, transactionExecutor1) // Should be the same cached instance + } + + @Test + fun whenFeatureFlagDisabledAndDefaultExecutorThenNullIsReturned() = runTest { + prepareSubject(false) + + val queryExecutor = subject.createQueryExecutor(DatabaseExecutor.Default) + val transactionExecutor = subject.createTransactionExecutor(DatabaseExecutor.Default) + + assertNull(queryExecutor) + assertNull(transactionExecutor) + } + + @Test + fun whenFeatureFlagEnabledAndCustomExecutorThenCustomExecutorsAreCreated() = runTest { + prepareSubject(true) + val customExecutor = DatabaseExecutor.Custom( + transactionPoolSize = 2, + queryPoolSize = 4, + transactionQueueSize = 6, + queryQueueSize = 10, + ) + + val queryExecutor = subject.createQueryExecutor(customExecutor) + val transactionExecutor = subject.createTransactionExecutor(customExecutor) + + assertNotNull(queryExecutor) + assertNotNull(transactionExecutor) + assertTrue(queryExecutor is ThreadPoolExecutor) + assertTrue(transactionExecutor is ThreadPoolExecutor) + + val queryThreadPool = queryExecutor as ThreadPoolExecutor + val transactionThreadPool = transactionExecutor as ThreadPoolExecutor + + assertEquals(4, queryThreadPool.corePoolSize) + assertEquals(4, queryThreadPool.maximumPoolSize) + assertEquals(10, queryThreadPool.queue.remainingCapacity() + queryThreadPool.queue.size) + + assertEquals(2, transactionThreadPool.corePoolSize) + assertEquals(2, transactionThreadPool.maximumPoolSize) + assertEquals(6, transactionThreadPool.queue.remainingCapacity() + transactionThreadPool.queue.size) + } + + @Test + fun whenFeatureFlagDisabledAndCustomExecutorThenLegacyExecutorsAreCreated() = runTest { + prepareSubject(false) + val customExecutor = DatabaseExecutor.Custom( + transactionPoolSize = 2, + queryPoolSize = 4, + ) + + val queryExecutor = subject.createQueryExecutor(customExecutor) + val transactionExecutor = subject.createTransactionExecutor(customExecutor) + + assertNotNull(queryExecutor) + assertNotNull(transactionExecutor) + assertTrue(queryExecutor is Executor) + assertTrue(transactionExecutor is Executor) + } + + @Test + fun whenCustomExecutorsCreatedMultipleTimesThenDifferentInstancesAreReturned() = runTest { + prepareSubject(true) + val customExecutor = DatabaseExecutor.Custom( + transactionPoolSize = 2, + queryPoolSize = 4, + ) + + val queryExecutor1 = subject.createQueryExecutor(customExecutor) + val queryExecutor2 = subject.createQueryExecutor(customExecutor) + val transactionExecutor1 = subject.createTransactionExecutor(customExecutor) + val transactionExecutor2 = subject.createTransactionExecutor(customExecutor) + + assertNotNull(queryExecutor1) + assertNotNull(queryExecutor2) + assertNotNull(transactionExecutor1) + assertNotNull(transactionExecutor2) + assertNotEquals(queryExecutor1, queryExecutor2) + assertNotEquals(transactionExecutor1, transactionExecutor2) + } + + private fun prepareSubject(flagEnabled: Boolean) { + databaseProviderFeature.self().setRawStoredState(State(enable = flagEnabled)) + subject = RealDatabaseExecutorProvider(databaseProviderFeature) + } +} diff --git a/data-store/data-store-impl/src/test/java/com/duckduckgo/data/store/impl/RoomDatabaseProviderImplTest.kt b/data-store/data-store-impl/src/test/java/com/duckduckgo/data/store/impl/RoomDatabaseProviderImplTest.kt new file mode 100644 index 000000000000..8d4b57c5be49 --- /dev/null +++ b/data-store/data-store-impl/src/test/java/com/duckduckgo/data/store/impl/RoomDatabaseProviderImplTest.kt @@ -0,0 +1,339 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.data.store.impl + +import android.annotation.SuppressLint +import android.content.Context +import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.data.store.api.DatabaseExecutor +import com.duckduckgo.data.store.api.RoomDatabaseConfig +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argThat +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.ExecutorService +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit.SECONDS + +private const val DEFAULT_POOL_SIZE = 6 +private const val CUSTOM_KEEP_ALIVE = 60L +private const val DEFAULT_KEEP_ALIVE = 0L +private const val DEFAULT_QUEUE_SIZE = DEFAULT_POOL_SIZE * 2 + +@SuppressLint("DenyListedApi") +@RunWith(AndroidJUnit4::class) +class RoomDatabaseProviderImplTest { + + private val context: Context = mock() + private val databaseProviderFeature: DatabaseProviderFeature = FakeFeatureToggleFactory.create(DatabaseProviderFeature::class.java) + private val roomDatabaseBuilderFactory: RoomDatabaseBuilderFactory = mock() + private val mockDatabase = mock() + private val mockRoomBuilder: RoomDatabase.Builder = mock() + private lateinit var subject: RoomDatabaseProviderImpl + + @Before + fun setUp() { + whenever(roomDatabaseBuilderFactory.createBuilder(eq(context), eq(TestDatabase::class.java), eq("test.db"))) + .thenReturn(mockRoomBuilder) + whenever(mockRoomBuilder.addMigrations(any())).thenReturn(mockRoomBuilder) + whenever(mockRoomBuilder.fallbackToDestructiveMigration()).thenReturn(mockRoomBuilder) + whenever(mockRoomBuilder.openHelperFactory(any())).thenReturn(mockRoomBuilder) + whenever(mockRoomBuilder.enableMultiInstanceInvalidation()).thenReturn(mockRoomBuilder) + whenever(mockRoomBuilder.setJournalMode(any())).thenReturn(mockRoomBuilder) + whenever(mockRoomBuilder.addCallback(any())).thenReturn(mockRoomBuilder) + whenever(mockRoomBuilder.fallbackToDestructiveMigrationFrom(any())).thenReturn(mockRoomBuilder) + whenever(mockRoomBuilder.setQueryExecutor(any())).thenReturn(mockRoomBuilder) + whenever(mockRoomBuilder.setTransactionExecutor(any())).thenReturn(mockRoomBuilder) + whenever(mockRoomBuilder.build()).thenReturn(mockDatabase) + } + + @Test + fun whenFeatureFlagDisabledAndCustomExecutorThenLegacyExecutorsAreUsed() = runTest { + prepareSubject(flagEnabled = false) + val config = RoomDatabaseConfig( + executor = DatabaseExecutor.Custom( + transactionPoolSize = 2, + queryPoolSize = 4, + ), + ) + + subject.buildRoomDatabase(TestDatabase::class.java, "test.db", config) + + verify(mockRoomBuilder).setQueryExecutor( + argThat { + (this as ThreadPoolExecutor).let { + it.corePoolSize == 4 && it.maximumPoolSize == 4 + } + }, + ) + verify(mockRoomBuilder).setTransactionExecutor( + argThat { + this is ExecutorService + }, + ) + } + + @Test + fun whenFeatureFlagEnabledAndCustomExecutorWithExplicitQueueSizesThenCustomExecutorsAreCreated() = runTest { + prepareSubject(flagEnabled = true) + databaseProviderFeature.self().setRawStoredState(State(enable = true)) + val customExecutor = DatabaseExecutor.Custom( + transactionPoolSize = 2, + queryPoolSize = 4, + transactionQueueSize = 6, + queryQueueSize = 10, + ) + val config = RoomDatabaseConfig(executor = customExecutor) + val queryArgumentCaptor = argumentCaptor() + val transactionArgumentCaptor = argumentCaptor() + + subject.buildRoomDatabase(TestDatabase::class.java, "test.db", config) + + verify(mockRoomBuilder).setQueryExecutor(queryArgumentCaptor.capture()) + with(queryArgumentCaptor.firstValue) { + this.let { + assertEquals(customExecutor.queryPoolSize, it.corePoolSize) + assertEquals(customExecutor.queryPoolSize, it.maximumPoolSize) + assertTrue(it.queue is ArrayBlockingQueue) + assertEquals(customExecutor.queryQueueSize, it.queue.size + it.queue.remainingCapacity()) + assertTrue(it.allowsCoreThreadTimeOut()) + assertEquals(CUSTOM_KEEP_ALIVE, it.getKeepAliveTime(SECONDS)) + } + } + verify(mockRoomBuilder).setTransactionExecutor(transactionArgumentCaptor.capture()) + with(transactionArgumentCaptor.firstValue) { + this.let { + assertEquals(customExecutor.transactionPoolSize, it.corePoolSize) + assertEquals(customExecutor.transactionPoolSize, it.maximumPoolSize) + assertTrue(it.queue is ArrayBlockingQueue) + assertEquals(customExecutor.transactionQueueSize, it.queue.size + it.queue.remainingCapacity()) + assertTrue(it.allowsCoreThreadTimeOut()) + assertEquals(CUSTOM_KEEP_ALIVE, it.getKeepAliveTime(SECONDS)) + } + } + } + + @Test + fun whenFeatureFlagEnabledAndDefaultExecutorThenDefaultExecutorIsUsed() = runTest { + prepareSubject(flagEnabled = true) + val config = RoomDatabaseConfig(executor = DatabaseExecutor.Default) + + subject.buildRoomDatabase(TestDatabase::class.java, "test.db", config) + + verify(mockRoomBuilder).setQueryExecutor( + argThat { + this is ExecutorService + }, + ) + verify(mockRoomBuilder).setTransactionExecutor( + argThat { + this is ExecutorService + }, + ) + } + + @Test + fun whenFeatureFlagDisabledAndDefaultExecutorThenNoExecutorsAreSet() = runTest { + prepareSubject(flagEnabled = false) + val config = RoomDatabaseConfig(executor = DatabaseExecutor.Default) + + subject.buildRoomDatabase(TestDatabase::class.java, "test.db", config) + + verify(mockRoomBuilder, never()).setTransactionExecutor(any()) + verify(mockRoomBuilder, never()).setQueryExecutor(any()) + } + + @Test + fun whenMigrationsProvidedThenMigrationsArePassedToBuilder() = runTest { + prepareSubject(flagEnabled = true) + val migration = mock() + val config = RoomDatabaseConfig(migrations = listOf(migration)) + + subject.buildRoomDatabase(TestDatabase::class.java, "test.db", config) + + verify(mockRoomBuilder).addMigrations(migration) + } + + @Test + fun whenFallbackToDestructiveMigrationEnabledThenFlagIsPassedToBuilder() = runTest { + prepareSubject(flagEnabled = true) + val config = RoomDatabaseConfig(fallbackToDestructiveMigration = true) + + subject.buildRoomDatabase(TestDatabase::class.java, "test.db", config) + + verify(mockRoomBuilder).fallbackToDestructiveMigration() + } + + @Test + fun whenOpenHelperFactoryProvidedThenFactoryIsPassedToBuilder() = runTest { + prepareSubject(flagEnabled = true) + val mockFactory = mock() + val config = RoomDatabaseConfig(openHelperFactory = mockFactory) + + subject.buildRoomDatabase(TestDatabase::class.java, "test.db", config) + + verify(mockRoomBuilder).openHelperFactory(mockFactory) + } + + @Test + fun whenMultiInstanceInvalidationEnabledThenFlagIsPassedToBuilder() = runTest { + prepareSubject(flagEnabled = true) + val config = RoomDatabaseConfig(enableMultiInstanceInvalidation = true) + + subject.buildRoomDatabase(TestDatabase::class.java, "test.db", config) + + verify(mockRoomBuilder).enableMultiInstanceInvalidation() + } + + @Test + fun whenJournalModeProvidedThenJournalModeIsPassedToBuilder() = runTest { + prepareSubject(flagEnabled = true) + val journalMode = RoomDatabase.JournalMode.TRUNCATE + val config = RoomDatabaseConfig(journalMode = journalMode) + + subject.buildRoomDatabase(TestDatabase::class.java, "test.db", config) + + verify(mockRoomBuilder).setJournalMode(journalMode) + } + + @Test + fun whenCallbacksProvidedThenCallbacksArePassedToBuilder() = runTest { + prepareSubject(flagEnabled = true) + val callback = mock() + val config = RoomDatabaseConfig(callbacks = listOf(callback)) + + subject.buildRoomDatabase(TestDatabase::class.java, "test.db", config) + + verify(mockRoomBuilder).addCallback(callback) + } + + @Test + fun whenFallbackToDestructiveMigrationFromVersionProvidedThenVersionsArePassedToBuilder() = runTest { + prepareSubject(flagEnabled = true) + val versions = listOf(1, 2, 3) + val config = RoomDatabaseConfig(fallbackToDestructiveMigrationFromVersion = versions) + + subject.buildRoomDatabase(TestDatabase::class.java, "test.db", config) + + verify(mockRoomBuilder).fallbackToDestructiveMigrationFrom(1, 2, 3) + } + + @Test + fun whenEmptyConfigProvidedThenDefaultValuesArePassedToBuilder() = runTest { + prepareSubject(flagEnabled = false) + val config = RoomDatabaseConfig() + + subject.buildRoomDatabase(TestDatabase::class.java, "test.db", config) + + verify(mockRoomBuilder, never()).setQueryExecutor(any()) + verify(mockRoomBuilder, never()).setTransactionExecutor(any()) + verify(mockRoomBuilder, never()).addMigrations(any()) + verify(mockRoomBuilder, never()).fallbackToDestructiveMigration() + verify(mockRoomBuilder, never()).openHelperFactory(any()) + verify(mockRoomBuilder, never()).enableMultiInstanceInvalidation() + verify(mockRoomBuilder, never()).setJournalMode(any()) + verify(mockRoomBuilder, never()).addCallback(any()) + } + + @Test + fun whenDefaultExecutorUsedThenThreadPoolExecutorIsCreatedWithDefaultConfig() = runTest { + prepareSubject(flagEnabled = true) + val config = RoomDatabaseConfig(executor = DatabaseExecutor.Default) + val queryArgumentCaptor = argumentCaptor() + val transactionArgumentCaptor = argumentCaptor() + + subject.buildRoomDatabase(TestDatabase::class.java, "test.db", config) + + verify(mockRoomBuilder).setQueryExecutor(queryArgumentCaptor.capture()) + with(queryArgumentCaptor.firstValue) { + this.let { + assertEquals(DEFAULT_POOL_SIZE, it.corePoolSize) + assertEquals(DEFAULT_POOL_SIZE, it.maximumPoolSize) + assertEquals(DEFAULT_KEEP_ALIVE, it.getKeepAliveTime(SECONDS)) + assertTrue(it.queue is ArrayBlockingQueue) + assertEquals(DEFAULT_QUEUE_SIZE, it.queue.remainingCapacity() + it.queue.size) + } + } + verify(mockRoomBuilder).setTransactionExecutor(transactionArgumentCaptor.capture()) + with(transactionArgumentCaptor.firstValue) { + this.let { + assertEquals(DEFAULT_POOL_SIZE, it.corePoolSize) + assertEquals(DEFAULT_POOL_SIZE, it.maximumPoolSize) + assertEquals(DEFAULT_KEEP_ALIVE, it.getKeepAliveTime(SECONDS)) + assertTrue(it.queue is ArrayBlockingQueue) + assertEquals(DEFAULT_QUEUE_SIZE, it.queue.remainingCapacity() + it.queue.size) + } + } + } + + @Test + fun whenSameCustomExecutorConfigUsedTwiceThenDifferentInstancesAreCreated() = runTest { + prepareSubject(flagEnabled = true) + val customExecutor = DatabaseExecutor.Custom(transactionPoolSize = 2, queryPoolSize = 4) + val config = RoomDatabaseConfig(executor = customExecutor) + val queryArgumentCaptor = argumentCaptor() + val transactionArgumentCaptor = argumentCaptor() + + subject.buildRoomDatabase(TestDatabase::class.java, "test.db", config) + subject.buildRoomDatabase(TestDatabase::class.java, "test.db", config) + + verify(mockRoomBuilder, times(2)).setQueryExecutor(queryArgumentCaptor.capture()) + verify(mockRoomBuilder, times(2)).setTransactionExecutor(transactionArgumentCaptor.capture()) + assertNotEquals(queryArgumentCaptor.firstValue, queryArgumentCaptor.secondValue) + assertNotEquals(transactionArgumentCaptor.firstValue, transactionArgumentCaptor.secondValue) + } + + // Test entity for testing purposes + @androidx.room.Entity(tableName = "test_entity") + data class TestEntity( + @androidx.room.PrimaryKey val id: Int, + val name: String, + ) + + // Test database class for testing purposes + @androidx.room.Database(version = 1, entities = [TestEntity::class], exportSchema = false) + abstract class TestDatabase : RoomDatabase() { + // Empty implementation for testing + } + + private fun prepareSubject(flagEnabled: Boolean) { + databaseProviderFeature.self().setRawStoredState(State(enable = flagEnabled)) + subject = RoomDatabaseProviderImpl( + context, + roomDatabaseBuilderFactory, + { RealDatabaseExecutorProvider(databaseProviderFeature) }, + ) + } +} diff --git a/site-permissions/site-permissions-impl/build.gradle b/site-permissions/site-permissions-impl/build.gradle index 51251ef41674..580de2207a53 100644 --- a/site-permissions/site-permissions-impl/build.gradle +++ b/site-permissions/site-permissions-impl/build.gradle @@ -34,6 +34,7 @@ dependencies { implementation project(path: ':privacy-config-api') implementation project(path: ':feature-toggles-api') implementation project(path: ':navigation-api') + implementation project(path: ':data-store-api') api project(path: ':site-permissions-store') anvil project(path: ':anvil-compiler') diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/di/SitePermissionsModule.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/di/SitePermissionsModule.kt index 3176bb0820f2..93e7332fe243 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/di/SitePermissionsModule.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/di/SitePermissionsModule.kt @@ -17,7 +17,8 @@ package com.duckduckgo.site.permissions.impl.di import android.content.Context -import androidx.room.Room +import com.duckduckgo.data.store.api.DatabaseProvider +import com.duckduckgo.data.store.api.RoomDatabaseConfig import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.site.permissions.store.ALL_MIGRATIONS import com.duckduckgo.site.permissions.store.SitePermissionsDatabase @@ -36,11 +37,12 @@ object SitePermissionsModule { @Provides @SingleInstanceIn(AppScope::class) - fun providesSitePermissionsDatabase(context: Context): SitePermissionsDatabase { - return Room.databaseBuilder(context, SitePermissionsDatabase::class.java, "site_permissions.db") - .fallbackToDestructiveMigration() - .addMigrations(*ALL_MIGRATIONS) - .build() + fun providesSitePermissionsDatabase(databaseProvider: DatabaseProvider): SitePermissionsDatabase { + return databaseProvider.buildRoomDatabase( + SitePermissionsDatabase::class.java, + "site_permissions.db", + config = RoomDatabaseConfig(fallbackToDestructiveMigration = true, migrations = ALL_MIGRATIONS), + ) } @Provides diff --git a/site-permissions/site-permissions-store/src/main/java/com/duckduckgo/site/permissions/store/SitePermissionsDatabase.kt b/site-permissions/site-permissions-store/src/main/java/com/duckduckgo/site/permissions/store/SitePermissionsDatabase.kt index 34476c326b73..501b775d5e71 100644 --- a/site-permissions/site-permissions-store/src/main/java/com/duckduckgo/site/permissions/store/SitePermissionsDatabase.kt +++ b/site-permissions/site-permissions-store/src/main/java/com/duckduckgo/site/permissions/store/SitePermissionsDatabase.kt @@ -56,4 +56,4 @@ val MIGRATION_4_5 = object : Migration(4, 5) { } } -val ALL_MIGRATIONS = arrayOf(MIGRATION_1_2, MIGRATION_3_4, MIGRATION_4_5) +val ALL_MIGRATIONS = listOf(MIGRATION_1_2, MIGRATION_3_4, MIGRATION_4_5)