diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 2914612..813f5f9 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -23,10 +23,13 @@ jobs: - name: Setup Gradle uses: gradle/gradle-build-action@842c587ad8aa4c68eeba24c396e15af4c2e9f30a - - name: Run tests + - name: Checks + run: ./gradlew check :app:koverXmlReportRelease :app:koverVerifyRelease + + - name: SonarCloud env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: ./gradlew check sonar --no-configuration-cache --no-daemon + run: ./gradlew sonar - name: Publish test reports uses: mikepenz/action-junit-report@0a8a5ba57593d67b2e45de2c543b438412382b7b diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0c4ecaf..5563987 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -84,71 +84,63 @@ ksp { } dependencies { - implementation(project(":architecture")) implementation(project(":database")) - testImplementation(project(":database_test")) implementation(project(":datastore")) - testImplementation(project(":datastore_test")) implementation(project(":datasource")) - testImplementation(project(":datasource_test")) implementation(project(":logging")) implementation(project(":ui")) implementation(project(":features:shopping_lists")) implementation(project(":model_mapper")) - testImplementation(project(":testing")) - implementation(libs.android.material.material) - implementation(libs.androidx.coreKtx) implementation(libs.androidx.splashScreen) - implementation(libs.androidx.appcompat) - implementation(libs.androidx.lifecycle.viewmodelKtx) - implementation(libs.androidx.shareTarget) - implementation(libs.androidx.compose.materialIconsExtended) - implementation(libs.google.dagger.hiltAndroid) - kapt(libs.google.dagger.hiltCompiler) - kaptTest(libs.google.dagger.hiltAndroidCompiler) - testImplementation(libs.google.dagger.hiltAndroidTesting) - kaptAndroidTest(libs.google.dagger.hiltAndroidCompiler) - androidTestImplementation(libs.google.dagger.hiltAndroidTesting) - implementation(libs.androidx.paging.runtimeKtx) implementation(libs.androidx.paging.compose) - testImplementation(libs.androidx.paging.commonKtx) - implementation(libs.jetbrains.kotlinx.datetime) - implementation(libs.androidx.datastore.preferences) - implementation(libs.coil) implementation(libs.coil.compose) - implementation(libs.androidx.compose.animation) - implementation(libs.androidx.hilt.navigationCompose) - - testImplementation(libs.junit) - implementation(libs.jetbrains.kotlinx.coroutinesAndroid) - testImplementation(libs.jetbrains.kotlinx.coroutinesTest) - - testImplementation(libs.robolectric) - - testImplementation(libs.androidx.test.junit) - testImplementation(libs.androidx.coreTesting) - - testImplementation(libs.google.truth) - - testImplementation(libs.io.mockk) debugImplementation(libs.squareup.leakcanary) + kover(project(":model_mapper")) + kover(project(":features:shopping_lists")) + kover(project(":ui")) + kover(project(":logging")) + kover(project(":architecture")) + kover(project(":database")) + kover(project(":datastore")) + kover(project(":datasource")) + + kapt(libs.google.dagger.hiltCompiler) + + kaptTest(libs.google.dagger.hiltAndroidCompiler) + + kaptAndroidTest(libs.google.dagger.hiltAndroidCompiler) + + testImplementation(project(":datasource_test")) + testImplementation(project(":database_test")) + testImplementation(project(":datastore_test")) + testImplementation(project(":testing")) + testImplementation(libs.androidx.paging.commonKtx) + testImplementation(libs.junit) + testImplementation(libs.jetbrains.kotlinx.coroutinesTest) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.junit) + testImplementation(libs.androidx.coreTesting) + testImplementation(libs.google.truth) + testImplementation(libs.io.mockk) + testImplementation(libs.google.dagger.hiltAndroidTesting) + androidTestImplementation(libs.junit) androidTestImplementation(libs.androidx.test.junit) androidTestImplementation(libs.kaspersky.kaspresso) @@ -157,5 +149,41 @@ dependencies { androidTestImplementation(libs.androidx.test.core) androidTestImplementation(libs.androidx.test.rules) androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.google.dagger.hiltAndroidTesting) + androidTestUtil(libs.androidx.test.orchestrator) -} \ No newline at end of file +} + +koverReport { + filters { + excludes { + classes( + "gq.kirmanak.mealient.datastore.recipe.AddRecipeInput*", // generated by data store + "*ComposableSingletons*", // generated by Compose + "gq.kirmanak.mealient.database.AppDb_Impl*", // generated by Room + "*Dao_Impl*", // generated by Room + "*Hilt_*", // generated by Hilt + ) + packages( + "gq.kirmanak.mealient*.destinations", // generated by Compose destinations + ) + annotatedBy( + "androidx.compose.ui.tooling.preview.Preview", + "gq.kirmanak.mealient.ui.preview.ColorSchemePreview", + "androidx.compose.runtime.Composable", + "dagger.Module", + "dagger.internal.DaggerGenerated", + ) + } + includes { + packages("gq.kirmanak.mealient") + } + } + androidReports("release") { + verify { + rule { + minBound(30) + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt index 4aee756..67ec28e 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -12,6 +12,7 @@ class AndroidApplicationConventionPlugin : Plugin { with(pluginManager) { apply("com.android.application") apply("org.jetbrains.kotlin.android") + apply("org.jetbrains.kotlinx.kover") } extensions.configure { diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt index 520bf85..6a82976 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -11,6 +11,7 @@ class AndroidLibraryConventionPlugin : Plugin { with(pluginManager) { apply("com.android.library") apply("org.jetbrains.kotlin.android") + apply("org.jetbrains.kotlinx.kover") } extensions.configure { diff --git a/build.gradle.kts b/build.gradle.kts index e877699..a6550a5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,6 +16,7 @@ buildscript { plugins { alias(libs.plugins.sonarqube) alias(libs.plugins.ksp) apply false + alias(libs.plugins.kover) apply false } sonarqube { @@ -23,6 +24,10 @@ sonarqube { property("sonar.projectKey", "kirmanak_Mealient") property("sonar.organization", "kirmanak") property("sonar.host.url", "https://sonarcloud.io") + property( + "sonar.coverage.jacoco.xmlReportPaths", + "${projectDir.path}/app/build/reports/kover/reportRelease.xml" + ) } } @@ -33,6 +38,7 @@ subprojects { "sonar.androidLint.reportPaths", "${projectDir.path}/build/reports/lint-results-debug.xml" ) + } } } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/ktor/AuthKtorConfiguration.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/ktor/AuthKtorConfiguration.kt index 176a6df..20da0ee 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/ktor/AuthKtorConfiguration.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/ktor/AuthKtorConfiguration.kt @@ -1,11 +1,13 @@ package gq.kirmanak.mealient.datasource.ktor +import androidx.annotation.VisibleForTesting import gq.kirmanak.mealient.datasource.AuthenticationProvider import gq.kirmanak.mealient.logging.Logger import io.ktor.client.HttpClientConfig import io.ktor.client.engine.HttpClientEngineConfig import io.ktor.client.plugins.auth.Auth import io.ktor.client.plugins.auth.providers.BearerTokens +import io.ktor.client.plugins.auth.providers.RefreshTokensParams import io.ktor.client.plugins.auth.providers.bearer import io.ktor.http.HttpStatusCode import javax.inject.Inject @@ -27,14 +29,7 @@ internal class AuthKtorConfiguration @Inject constructor( } refreshTokens { - val newTokens = getTokens() - val sameAccessToken = newTokens?.accessToken == oldTokens?.accessToken - if (sameAccessToken && response.status == HttpStatusCode.Unauthorized) { - authenticationProvider.logout() - null - } else { - newTokens - } + refreshTokens() } sendWithoutRequest { true } @@ -42,7 +37,20 @@ internal class AuthKtorConfiguration @Inject constructor( } } - private suspend fun getTokens(): BearerTokens? { + @VisibleForTesting + suspend fun RefreshTokensParams.refreshTokens(): BearerTokens? { + val newTokens = getTokens() + val sameAccessToken = newTokens?.accessToken == oldTokens?.accessToken + return if (sameAccessToken && response.status == HttpStatusCode.Unauthorized) { + authenticationProvider.logout() + null + } else { + newTokens + } + } + + @VisibleForTesting + suspend fun getTokens(): BearerTokens? { val token = authenticationProvider.getAuthToken() logger.v { "getTokens(): token = $token" } return token?.let { BearerTokens(accessToken = it, refreshToken = "") } diff --git a/datasource/src/test/kotlin/gq/kirmanak/mealient/datasource/AuthKtorConfigurationTest.kt b/datasource/src/test/kotlin/gq/kirmanak/mealient/datasource/AuthKtorConfigurationTest.kt new file mode 100644 index 0000000..d68cda1 --- /dev/null +++ b/datasource/src/test/kotlin/gq/kirmanak/mealient/datasource/AuthKtorConfigurationTest.kt @@ -0,0 +1,109 @@ +package gq.kirmanak.mealient.datasource + +import com.google.common.truth.Truth.assertThat +import gq.kirmanak.mealient.datasource.ktor.AuthKtorConfiguration +import gq.kirmanak.mealient.test.BaseUnitTest +import io.ktor.client.plugins.auth.providers.BearerTokens +import io.ktor.client.plugins.auth.providers.RefreshTokensParams +import io.ktor.client.statement.HttpResponse +import io.ktor.http.HttpStatusCode +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +private const val AUTH_TOKEN = "token" + +internal class AuthKtorConfigurationTest : BaseUnitTest() { + + @MockK(relaxUnitFun = true) + lateinit var authenticationProvider: AuthenticationProvider + + private lateinit var subject: AuthKtorConfiguration + + @Before + override fun setUp() { + super.setUp() + coEvery { authenticationProvider.getAuthToken() } returns AUTH_TOKEN + subject = AuthKtorConfiguration(FakeProvider(authenticationProvider), logger) + } + + @Test + fun `getTokens returns BearerTokens with auth token`() = runTest { + val bearerTokens = subject.getTokens() + assertThat(bearerTokens?.accessToken).isEqualTo(AUTH_TOKEN) + } + + @Test + fun `getTokens returns BearerTokens without refresh token`() = runTest { + val bearerTokens = subject.getTokens() + assertThat(bearerTokens?.refreshToken).isEmpty() + } + + @Test + fun `refreshTokens returns new auth token if it doesn't match old`() = runTest { + val refreshTokensParams = mockRefreshTokenParams(HttpStatusCode.Unauthorized, "old token") + val actual = with(subject) { refreshTokensParams.refreshTokens() } + assertThat(actual?.accessToken).isEqualTo(AUTH_TOKEN) + } + + @Test + fun `refreshTokens returns empty refresh token if auth token doesn't match old`() = runTest { + val refreshTokensParams = mockRefreshTokenParams(HttpStatusCode.Unauthorized, "old token") + val actual = with(subject) { refreshTokensParams.refreshTokens() } + assertThat(actual?.refreshToken).isEmpty() + } + + @Test + fun `refreshTokens returns null if auth token matches old`() = runTest { + val refreshTokensParams = mockRefreshTokenParams(HttpStatusCode.Unauthorized, AUTH_TOKEN) + val actual = with(subject) { refreshTokensParams.refreshTokens() } + assertThat(actual).isNull() + } + + @Test + fun `refreshTokens calls logout if auth token matches old`() = runTest { + val refreshTokensParams = mockRefreshTokenParams(HttpStatusCode.Unauthorized, AUTH_TOKEN) + with(subject) { refreshTokensParams.refreshTokens() } + coVerify { authenticationProvider.logout() } + } + + @Test + fun `refreshTokens does not logout if status code is not found`() = runTest { + val refreshTokensParams = mockRefreshTokenParams(HttpStatusCode.NotFound, AUTH_TOKEN) + with(subject) { refreshTokensParams.refreshTokens() } + coVerify(inverse = true) { authenticationProvider.logout() } + } + + @Test + fun `refreshTokens returns same access token if status code is not found`() = runTest { + val refreshTokensParams = mockRefreshTokenParams(HttpStatusCode.NotFound, AUTH_TOKEN) + val actual = with(subject) { refreshTokensParams.refreshTokens() } + assertThat(actual?.accessToken).isEqualTo(AUTH_TOKEN) + } + + @Test + fun `refreshTokens returns empty refresh token if status code is not found`() = runTest { + val refreshTokensParams = mockRefreshTokenParams(HttpStatusCode.NotFound, AUTH_TOKEN) + val actual = with(subject) { refreshTokensParams.refreshTokens() } + assertThat(actual?.refreshToken).isEmpty() + } + + private fun mockRefreshTokenParams( + responseStatusCode: HttpStatusCode, + oldAccessToken: String, + ): RefreshTokensParams { + val notFoundResponse = mockk { + every { status } returns responseStatusCode + } + val refreshTokensParams = mockk { + every { response } returns notFoundResponse + every { oldTokens } returns BearerTokens(oldAccessToken, "") + } + return refreshTokensParams + } +} \ No newline at end of file diff --git a/datasource/src/test/kotlin/gq/kirmanak/mealient/datasource/FakeProvider.kt b/datasource/src/test/kotlin/gq/kirmanak/mealient/datasource/FakeProvider.kt new file mode 100644 index 0000000..6a7ee5c --- /dev/null +++ b/datasource/src/test/kotlin/gq/kirmanak/mealient/datasource/FakeProvider.kt @@ -0,0 +1,10 @@ +package gq.kirmanak.mealient.datasource + +import javax.inject.Provider + +data class FakeProvider( + val value: T, +) : Provider { + + override fun get(): T = value +} diff --git a/features/shopping_lists/build.gradle.kts b/features/shopping_lists/build.gradle.kts index fe428b6..94cf630 100644 --- a/features/shopping_lists/build.gradle.kts +++ b/features/shopping_lists/build.gradle.kts @@ -23,25 +23,22 @@ dependencies { implementation(project(":database")) implementation(project(":ui")) implementation(project(":model_mapper")) - implementation(libs.android.material.material) implementation(libs.androidx.compose.material) implementation(libs.androidx.compose.materialIconsExtended) - implementation(libs.google.dagger.hiltAndroid) - kapt(libs.google.dagger.hiltCompiler) - kaptTest(libs.google.dagger.hiltAndroidCompiler) - testImplementation(libs.google.dagger.hiltAndroidTesting) - implementation(libs.androidx.hilt.navigationCompose) - implementation(libs.jetbrains.kotlinx.coroutinesAndroid) + + kapt(libs.google.dagger.hiltCompiler) + + kaptTest(libs.google.dagger.hiltAndroidCompiler) + + testImplementation(project(":testing")) + testImplementation(libs.google.dagger.hiltAndroidTesting) testImplementation(libs.jetbrains.kotlinx.coroutinesTest) - testImplementation(libs.androidx.test.junit) - testImplementation(libs.google.truth) - testImplementation(libs.io.mockk) } diff --git a/features/shopping_lists/src/test/kotlin/gq/kirmanak/mealient/shopping_lists/ui/details/ShoppingListViewModelTest.kt b/features/shopping_lists/src/test/kotlin/gq/kirmanak/mealient/shopping_lists/ui/details/ShoppingListViewModelTest.kt new file mode 100644 index 0000000..7cd89be --- /dev/null +++ b/features/shopping_lists/src/test/kotlin/gq/kirmanak/mealient/shopping_lists/ui/details/ShoppingListViewModelTest.kt @@ -0,0 +1,204 @@ +package gq.kirmanak.mealient.shopping_lists.ui.details + +import androidx.lifecycle.SavedStateHandle +import gq.kirmanak.mealient.datasource.models.GetFoodResponse +import gq.kirmanak.mealient.datasource.models.GetShoppingListItemRecipeReferenceResponse +import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse +import gq.kirmanak.mealient.datasource.models.GetShoppingListResponse +import gq.kirmanak.mealient.datasource.models.GetUnitResponse +import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsAuthRepo +import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepo +import gq.kirmanak.mealient.test.BaseUnitTest +import gq.kirmanak.mealient.ui.util.LoadingHelper +import gq.kirmanak.mealient.ui.util.LoadingHelperFactory +import gq.kirmanak.mealient.ui.util.LoadingState +import gq.kirmanak.mealient.ui.util.LoadingStateNoData +import gq.kirmanak.mealient.ui.util.LoadingStateWithData +import io.mockk.CapturingSlot +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.slot +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test +import java.io.IOException + +internal class ShoppingListViewModelTest : BaseUnitTest() { + + @MockK(relaxUnitFun = true) + lateinit var shoppingListsRepo: ShoppingListsRepo + + @MockK(relaxUnitFun = true) + lateinit var authRepo: ShoppingListsAuthRepo + + @MockK(relaxUnitFun = true) + lateinit var loadingHelperFactory: LoadingHelperFactory + + @MockK(relaxUnitFun = true) + lateinit var loadingHelper: LoadingHelper + + lateinit var subject: ShoppingListViewModel + + private val loadingState = MutableStateFlow>( + LoadingStateNoData.InitialLoad + ) + + private val isAuthorized = MutableStateFlow(false) + + @Test + fun `when view model is created then the list is refreshed`() { + createViewModel() + coVerify { loadingHelper.refresh() } + } + + @Test + fun `when user authenticates then the list is refreshed`() { + createViewModel() + isAuthorized.value = true + coVerify { + loadingHelper.refresh() // On create + loadingHelper.refresh() // On authentication + } + } + + @Test + fun `when refresh fails then snackbar is shown`() { + val error = IOException() + createViewModel( + refreshResult = Result.failure(error) + ) + assertSame(error, subject.errorToShowInSnackbar) + } + + + @Test + fun `when refresh succeeds then no snackbar shown`() { + createViewModel() + assertNull(subject.errorToShowInSnackbar) + } + + @Test + fun `when loading starts then state is initial load`() { + createViewModel() + assertEquals(LoadingStateNoData.InitialLoad, subject.loadingState.value) + } + + @Test + fun `when loading succeeds then data is shown`() { + createViewModel() + loadingState.value = LoadingStateWithData.Success(shoppingListData) + assertEquals(LoadingStateWithData.Success(shoppingListScreen), subject.loadingState.value) + } + + @Test + fun `when load data is requested then repo is queried`() = runTest { + val lambdaSlot = slot Result>() + createViewModel( + lambdaSlot = lambdaSlot + ) + val lambda = lambdaSlot.captured + val actualResult = lambda() + assertEquals(Result.success(shoppingListData), actualResult) + } + + private fun createViewModel( + shoppingListId: String = "shoppingListId", + refreshResult: Result = Result.success(shoppingListData), + lambdaSlot: CapturingSlot Result> = slot Result>(), + ) { + val savedStateHandle = SavedStateHandle().also { + it["shoppingListId"] = shoppingListId + } + every { loadingHelperFactory.create(any(), capture(lambdaSlot)) } returns loadingHelper + every { loadingHelper.loadingState } returns loadingState + coEvery { loadingHelper.refresh() } returns refreshResult + every { authRepo.isAuthorizedFlow } returns isAuthorized + coEvery { shoppingListsRepo.getFoods() } returns listOf(milkFood) + coEvery { shoppingListsRepo.getUnits() } returns listOf(mlUnit) + coEvery { shoppingListsRepo.getShoppingList(any()) } returns shoppingListResponse + subject = ShoppingListViewModel( + shoppingListsRepo = shoppingListsRepo, + logger = logger, + authRepo = authRepo, + loadingHelperFactory = loadingHelperFactory, + savedStateHandle = savedStateHandle + ) + } +} + + +private val mlUnit = GetUnitResponse("ml", "") + +private val milkFood = GetFoodResponse("Milk", "") + +private val blackTeaBags = GetShoppingListItemResponse( + id = "1", + shoppingListId = "1", + checked = false, + position = 0, + isFood = false, + note = "Black tea bags", + quantity = 30.0, + unit = null, + food = null, + recipeReferences = listOf( + GetShoppingListItemRecipeReferenceResponse( + recipeId = "1", + recipeQuantity = 1.0, + ), + ), +) + +private val milk = GetShoppingListItemResponse( + id = "2", + shoppingListId = "1", + checked = true, + position = 0, + isFood = true, + note = "Cold", + quantity = 500.0, + unit = mlUnit, + food = milkFood, + recipeReferences = listOf( + GetShoppingListItemRecipeReferenceResponse( + recipeId = "1", + recipeQuantity = 500.0, + ), + ), +) + +private val shoppingListResponse = GetShoppingListResponse( + id = "shoppingListId", + groupId = "shoppingListGroupId", + name = "shoppingListName", + listItems = listOf(blackTeaBags, milk), + recipeReferences = listOf() +) + +private val shoppingListData = ShoppingListData( + foods = listOf(milkFood), + units = listOf(mlUnit), + shoppingList = shoppingListResponse +) + +private val shoppingListScreen = ShoppingListScreenState( + name = "shoppingListName", + listId = "shoppingListId", + items = listOf( + ShoppingListItemState.ExistingItem( + item = blackTeaBags, + isEditing = false + ), + ShoppingListItemState.ExistingItem( + item = milk, + isEditing = false + ) + ), + foods = listOf(milkFood), + units = listOf(mlUnit) +) \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ace926a..eb9ff4e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -83,6 +83,8 @@ androidxHilt = "1.1.0" ktor = "2.3.7" # https://github.com/coil-kt/coil/releases coil = "2.5.0" +# https://github.com/Kotlin/kotlinx-kover/releases +kover = "0.7.5" [libraries] android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } @@ -191,3 +193,4 @@ sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" } appsweep = { id = "com.guardsquare.appsweep", version.ref = "appsweep" } ksp = { id = "com.google.devtools.ksp", version.ref = "kspPlugin" } protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" } +kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 1256298..17f1746 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,6 +23,8 @@ dependencyResolutionManagement { rootProject.name = "Mealient" +System.setProperty("sonar.gradle.skipCompile", "true") + include(":app") include(":architecture") include(":database")