Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 878ae084 authored by Alejandro Nijamkin's avatar Alejandro Nijamkin
Browse files

SysUI linter: Clean Architecture Dependency Rule.

Implements a System UI linter check for the Dependency Rule from Clean
Architecture.

Fix: 263294875
Test: unit tests included.
Change-Id: I9e5b0b56ca654e327803f80d7b3687e56d58b15c
parent 7eb00fa9
Loading
Loading
Loading
Loading
+150 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 The Android Open Source Project
 *
 * 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.android.internal.systemui.lint

import com.android.tools.lint.client.api.UElementHandler
import com.android.tools.lint.detector.api.Category
import com.android.tools.lint.detector.api.Detector
import com.android.tools.lint.detector.api.Implementation
import com.android.tools.lint.detector.api.Issue
import com.android.tools.lint.detector.api.JavaContext
import com.android.tools.lint.detector.api.Scope
import com.android.tools.lint.detector.api.Severity
import org.jetbrains.uast.UElement
import org.jetbrains.uast.UFile
import org.jetbrains.uast.UImportStatement

/**
 * Detects violations of the Dependency Rule of Clean Architecture.
 *
 * The rule states that code in each layer may only depend on code in the same layer or the layer
 * directly "beneath" that layer in the layer diagram.
 *
 * In System UI, we have three layers; from top to bottom, they are: ui, domain, and data. As a
 * convention, was used packages with those names to place code in the appropriate layer. We also
 * make an exception and allow for shared models to live under a separate package named "shared" to
 * avoid code duplication.
 *
 * For more information, please see go/sysui-arch.
 */
@Suppress("UnstableApiUsage")
class CleanArchitectureDependencyViolationDetector : Detector(), Detector.UastScanner {
    override fun getApplicableUastTypes(): List<Class<out UElement>> {
        return listOf(UFile::class.java)
    }

    override fun createUastHandler(context: JavaContext): UElementHandler {
        return object : UElementHandler() {
            override fun visitFile(node: UFile) {
                // Check which Clean Architecture layer this file belongs to:
                matchingLayer(node.packageName)?.let { layer ->
                    // The file matches with a Clean Architecture layer. Let's check all of its
                    // imports.
                    node.imports.forEach { importStatement ->
                        visitImportStatement(context, layer, importStatement)
                    }
                }
            }
        }
    }

    private fun visitImportStatement(
        context: JavaContext,
        layer: Layer,
        importStatement: UImportStatement,
    ) {
        val importText = importStatement.importReference?.asSourceString() ?: return
        val importedLayer = matchingLayer(importText) ?: return

        // Now check whether the layer of the file may depend on the layer of the import.
        if (!layer.mayDependOn(importedLayer)) {
            context.report(
                issue = ISSUE,
                scope = importStatement,
                location = context.getLocation(importStatement),
                message =
                    "The ${layer.packageNamePart} layer may not depend on" +
                        " the ${importedLayer.packageNamePart} layer.",
            )
        }
    }

    private fun matchingLayer(packageName: String): Layer? {
        val packageNameParts = packageName.split(".").toSet()
        return Layer.values()
            .filter { layer -> packageNameParts.contains(layer.packageNamePart) }
            .takeIf { it.size == 1 }
            ?.first()
    }

    private enum class Layer(
        val packageNamePart: String,
        val canDependOn: Set<Layer>,
    ) {
        SHARED(
            packageNamePart = "shared",
            canDependOn = emptySet(), // The shared layer may not depend on any other layer.
        ),
        DATA(
            packageNamePart = "data",
            canDependOn = setOf(SHARED),
        ),
        DOMAIN(
            packageNamePart = "domain",
            canDependOn = setOf(SHARED, DATA),
        ),
        UI(
            packageNamePart = "ui",
            canDependOn = setOf(DOMAIN, SHARED),
        ),
        ;

        fun mayDependOn(otherLayer: Layer): Boolean {
            return this == otherLayer || canDependOn.contains(otherLayer)
        }
    }

    companion object {
        @JvmStatic
        val ISSUE =
            Issue.create(
                id = "CleanArchitectureDependencyViolation",
                briefDescription = "Violation of the Clean Architecture Dependency Rule.",
                explanation =
                    """
                    Following the \"Dependency Rule\" from Clean Architecture, every layer of code \
                    can only depend code in its own layer or code in the layer directly \
                    \"beneath\" it. Therefore, the UI layer can only depend on the" Domain layer \
                    and the Domain layer can only depend on the Data layer. We" do make an \
                    exception to allow shared models to exist and be shared across layers by \
                    placing them under shared/model, which should be done with care. For more \
                    information about Clean Architecture in System UI, please see go/sysui-arch. \
                    NOTE: if your code is not using Clean Architecture, please feel free to ignore \
                    this warning.
                """,
                category = Category.CORRECTNESS,
                priority = 8,
                severity = Severity.WARNING,
                implementation =
                    Implementation(
                        CleanArchitectureDependencyViolationDetector::class.java,
                        Scope.JAVA_FILE_SCOPE,
                    ),
            )
    }
}
+9 −7
Original line number Diff line number Diff line
@@ -27,9 +27,11 @@ import com.google.auto.service.AutoService
class SystemUIIssueRegistry : IssueRegistry() {

    override val issues: List<Issue>
        get() = listOf(
        get() =
            listOf(
                BindServiceOnMainThreadDetector.ISSUE,
                BroadcastSentViaContextDetector.ISSUE,
                CleanArchitectureDependencyViolationDetector.ISSUE,
                SlowUserQueryDetector.ISSUE_SLOW_USER_ID_QUERY,
                SlowUserQueryDetector.ISSUE_SLOW_USER_INFO_QUERY,
                NonInjectedMainThreadDetector.ISSUE,
+296 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 The Android Open Source Project
 *
 * 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.android.internal.systemui.lint

import com.android.tools.lint.checks.infrastructure.TestFiles
import com.android.tools.lint.checks.infrastructure.TestMode
import com.android.tools.lint.detector.api.Detector
import com.android.tools.lint.detector.api.Issue
import org.junit.Ignore
import org.junit.Test

@Suppress("UnstableApiUsage")
@Ignore("b/254533331")
class CleanArchitectureDependencyViolationDetectorTest : SystemUILintDetectorTest() {
    override fun getDetector(): Detector {
        return CleanArchitectureDependencyViolationDetector()
    }

    override fun getIssues(): List<Issue> {
        return listOf(
            CleanArchitectureDependencyViolationDetector.ISSUE,
        )
    }

    @Test
    fun `No violations`() {
        lint()
            .files(
                *LEGITIMATE_FILES,
            )
            .issues(
                CleanArchitectureDependencyViolationDetector.ISSUE,
            )
            .run()
            .expectWarningCount(0)
    }

    @Test
    fun `Violation - domain depends on ui`() {
        lint()
            .files(
                *LEGITIMATE_FILES,
                TestFiles.kotlin(
                    """
                        package test.domain.interactor

                        import test.ui.viewmodel.ViewModel

                        class BadClass(
                            private val viewModel: ViewModel,
                        )
                    """.trimIndent()
                )
            )
            .issues(
                CleanArchitectureDependencyViolationDetector.ISSUE,
            )
            .testModes(TestMode.DEFAULT)
            .run()
            .expectWarningCount(1)
            .expect(
                expectedText =
                    """
                    src/test/domain/interactor/BadClass.kt:3: Warning: The domain layer may not depend on the ui layer. [CleanArchitectureDependencyViolation]
                    import test.ui.viewmodel.ViewModel
                    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                    0 errors, 1 warnings
                """,
            )
    }

    @Test
    fun `Violation - ui depends on data`() {
        lint()
            .files(
                *LEGITIMATE_FILES,
                TestFiles.kotlin(
                    """
                        package test.ui.viewmodel

                        import test.data.repository.Repository

                        class BadClass(
                            private val repository: Repository,
                        )
                    """.trimIndent()
                )
            )
            .issues(
                CleanArchitectureDependencyViolationDetector.ISSUE,
            )
            .testModes(TestMode.DEFAULT)
            .run()
            .expectWarningCount(1)
            .expect(
                expectedText =
                    """
                    src/test/ui/viewmodel/BadClass.kt:3: Warning: The ui layer may not depend on the data layer. [CleanArchitectureDependencyViolation]
                    import test.data.repository.Repository
                    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                    0 errors, 1 warnings
                """,
            )
    }

    @Test
    fun `Violation - shared depends on all other layers`() {
        lint()
            .files(
                *LEGITIMATE_FILES,
                TestFiles.kotlin(
                    """
                        package test.shared.model

                        import test.data.repository.Repository
                        import test.domain.interactor.Interactor
                        import test.ui.viewmodel.ViewModel

                        class BadClass(
                            private val repository: Repository,
                            private val interactor: Interactor,
                            private val viewmodel: ViewModel,
                        )
                    """.trimIndent()
                )
            )
            .issues(
                CleanArchitectureDependencyViolationDetector.ISSUE,
            )
            .testModes(TestMode.DEFAULT)
            .run()
            .expectWarningCount(3)
            .expect(
                expectedText =
                    """
                    src/test/shared/model/BadClass.kt:3: Warning: The shared layer may not depend on the data layer. [CleanArchitectureDependencyViolation]
                    import test.data.repository.Repository
                    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                    src/test/shared/model/BadClass.kt:4: Warning: The shared layer may not depend on the domain layer. [CleanArchitectureDependencyViolation]
                    import test.domain.interactor.Interactor
                    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                    src/test/shared/model/BadClass.kt:5: Warning: The shared layer may not depend on the ui layer. [CleanArchitectureDependencyViolation]
                    import test.ui.viewmodel.ViewModel
                    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                    0 errors, 3 warnings
                """,
            )
    }

    @Test
    fun `Violation - data depends on domain`() {
        lint()
            .files(
                *LEGITIMATE_FILES,
                TestFiles.kotlin(
                    """
                        package test.data.repository

                        import test.domain.interactor.Interactor

                        class BadClass(
                            private val interactor: Interactor,
                        )
                    """.trimIndent()
                )
            )
            .issues(
                CleanArchitectureDependencyViolationDetector.ISSUE,
            )
            .testModes(TestMode.DEFAULT)
            .run()
            .expectWarningCount(1)
            .expect(
                expectedText =
                    """
                    src/test/data/repository/BadClass.kt:3: Warning: The data layer may not depend on the domain layer. [CleanArchitectureDependencyViolation]
                    import test.domain.interactor.Interactor
                    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                    0 errors, 1 warnings
                """,
            )
    }

    companion object {
        private val MODEL_FILE =
            TestFiles.kotlin(
                """
                    package test.shared.model

                    import test.some.other.thing.SomeOtherThing

                    data class Model(
                        private val name: String,
                    )
                """.trimIndent()
            )
        private val REPOSITORY_FILE =
            TestFiles.kotlin(
                """
                    package test.data.repository

                    import test.shared.model.Model
                    import test.some.other.thing.SomeOtherThing

                    class Repository {
                        private val models = listOf(
                            Model("one"),
                            Model("two"),
                            Model("three"),
                        )

                        fun getModels(): List<Model> {
                            return models
                        }
                    }
                """.trimIndent()
            )
        private val INTERACTOR_FILE =
            TestFiles.kotlin(
                """
                    package test.domain.interactor

                    import test.data.repository.Repository
                    import test.shared.model.Model

                    class Interactor(
                        private val repository: Repository,
                    ) {
                        fun getModels(): List<Model> {
                            return repository.getModels()
                        }
                    }
                """.trimIndent()
            )
        private val VIEW_MODEL_FILE =
            TestFiles.kotlin(
                """
                    package test.ui.viewmodel

                    import test.domain.interactor.Interactor
                    import test.some.other.thing.SomeOtherThing

                    class ViewModel(
                        private val interactor: Interactor,
                    ) {
                        fun getNames(): List<String> {
                            return interactor.getModels().map { model -> model.name }
                        }
                    }
                """.trimIndent()
            )
        private val NON_CLEAN_ARCHITECTURE_FILE =
            TestFiles.kotlin(
                """
                    package test.some.other.thing

                    import test.data.repository.Repository
                    import test.domain.interactor.Interactor
                    import test.ui.viewmodel.ViewModel

                    class SomeOtherThing {
                        init {
                            val viewModel = ViewModel(
                                interactor = Interactor(
                                    repository = Repository(),
                                ),
                            )
                        }
                    }
                """.trimIndent()
            )
        private val LEGITIMATE_FILES =
            arrayOf(
                MODEL_FILE,
                REPOSITORY_FILE,
                INTERACTOR_FILE,
                VIEW_MODEL_FILE,
                NON_CLEAN_ARCHITECTURE_FILE,
            )
    }
}