Skip to main content

Command Palette

Search for a command to run...

How To Win Friends And Influence Internal Visibility

Updated
7 min read
How To Win Friends And Influence Internal Visibility
J

Hey! I’m Joe! I’m an Android developer who is passionate about the human side of software development.

Intro

I recently learned about friendPaths as part of the Kotlin Gradle Plugin while browsing an Android Slack group. From the documentation on friendPaths: "Paths to the output directories of the friend modules whose internal declarations should be visible."

This struck some joy in me when I thought about modules being 'friends' and allowing one friend to have visibility into internal members. Additionally, its use case within my project became immediately obvious. First, some backstory on the setup of the project.

We use a modularization strategy roughly outlined in this talk titled "Android At Scale @Square" from Ralf Wondratschek.

For the purpose of this post, the modularization strategy can be summarized by every logical module having 3 actual modules.

  1. A public facing API module

  2. An internal implementation module

  3. A wiring / glue module to bind the API with the implementation

The wiring module depends on the public module via the api Gradle configuration and depends on the impl module via the implementation Gradle configuration. The app module then depends on the wiring module.

This simplified strategy is perfectly reasonable but has one caveat. In order for the wiring module to bind the public API and the internal impl modules, the members of impl actually can't be internal because the wiring module needs visibility into them.

Befriending wiring and impl

In this setup, wiring and impl have a special relationship. One could describe this relationship as friendly even! The ideal state would allow impl to have its members be internal while still allowing wiring to peek at them to bind them in our DI graph.

Show Me The Code

The context of the below code occurs in the assorted configuration of our Gradle Plugin.

To wire up this friendly relationship, I first created a new Gradle configuration that I've called friendlyImplementation (The idea for this was shamelessly borrowed from Aurimas Liutikas)

val friendlyImplementation =  
    configurations.create("friendlyImplementation") {  
        isCanBeResolved = true  
        isCanBeConsumed = false  
        isTransitive = false  
    }  

// Make implementation extend from friendlyImplementation so dependencies are available  
configurations.findByName("implementation")?.extendsFrom(friendlyImplementation)

Now that we have this new configuration, we can have our wiring module depend on our impl module via this new friendlyImplementation configuration

This will work to add a module as a dependency but won't actually do anything for us yet. If we try to access an internal property within impl, we will see both an error in Android Studio and we will see an error from the Kotlin compiler

Configuring friendlyImplementation

private fun Project.configureFriendModules() {  
    val friendlyImplementation =  
        configurations.create("friendlyImplementation") {  
            isCanBeResolved = true  
            isCanBeConsumed = false  
            isTransitive = false  
        }  

    // Make implementation extend from friendlyImplementation so dependencies are available  
    configurations.findByName("implementation")?.extendsFrom(friendlyImplementation)  

    friendlyImplementation.dependencies.configureEach {  
        // assume any friendlyImplementation dependency is within same parent project  
        val friendlyPath = this@configureFriendModules.path.substringBeforeLast(":") + ":$name"  

        val friendlyProjectPath =  
            rootProject  
                .findProject(friendlyPath)  
                ?.layout  
                ?.buildDirectory  
                ?.get()  
                ?.asFile  
                ?.absolutePath  

        if (friendlyProjectPath != null) {  
            this@configureFriendModules.tasks  
                .withType(KotlinCompile::class.java)  
                .configureEach { friendPaths.from(files(friendlyProjectPath)) }  
        } else {  
            logger.error(  
                "Attempting to configure friendly project failed for project ${this@configureFriendModules.name} with dependency $name"  
            )  
        }  
    }  
}

When going to configure our friendlyImplementation configuration, the goal is to look at all of the dependencies included under friendlyImplementation, get that dependency's build directory, and add it as a friend via friendPaths

Battling Android Studio

After adding this to our Gradle plugin, this setup allows us to get successful compilations! But presents one annoying developer experience issue: Android Studio still presents the error in the screenshot above. This experience is not ideal for developers on the team who usually have high trust when Android Studio tells them something is wrong (issue filed)

Luckily for me, we have the infrastructure in place already for a custom Android Studio plugin that is distributed internally and installed on our developers machines. Having a distributed internal Android Studio plugin is a great tool in an arsenal to help with the developer experience of a team.

IntelliJ exposes the ability for us to interact with this error via HighlightInfoFilter. The gist of the API of this filter is given some information about HighlightInfo should that highlight display. In our implementation, we will look at the HighlightInfos description, do some fuzzy matching against our INVISIBLE_REFERENCE from earlier, and hide that error if its in a module that has friendlyImplementation dependencies (I'm waving my hand at the exactness of this algorithm. The eagle eyed amongst anyone reading this can point out a case in which this algorithm fails, because for example we don't match the member to a given friendlyImplementation dependency.)

public class FriendPathHighlightInfoFilter : HighlightInfoFilter {  

    override fun accept(highlightInfo: HighlightInfo, psiFile: PsiFile?): Boolean {  
        // Only process error-level highlights  
        if (highlightInfo.severity != HighlightSeverity.ERROR) {  
            return true  
        }  

        // Check if this is an internal visibility error  
        val description = highlightInfo.description ?: return true  
        val isInternalError = description.contains("[INVISIBLE_REFERENCE]", ignoreCase = true)  

        if (!isInternalError) {  
            return true  
        }  

        // Check if we're in a glue module (which has friend path access)  
        if (psiFile == null) {  
            return true  
        }  

        val module = ModuleUtilCore.findModuleForFile(psiFile.virtualFile, psiFile.project)  
            ?: return true  

        // Check if this module has friendlyImplementation dependencies  
        val detector = FriendModuleDetector.getInstance(psiFile.project)  
        if (detector.hasFriendlyImplementation(module)) {  
            // Return false to suppress (hide) this error  
            return false  
        }  

        // Allow the error to be shown in other modules  
        return true  
    }  
}

We then create a service that has a cache of module's build files so we can check to see if a module has dependencies via friendlyImplementation

@Service(Service.Level.PROJECT)  
public class FriendModuleDetector {  

    private val cache = ConcurrentHashMap<String, Boolean>()  

    public companion object {  
        public fun getInstance(project: Project): FriendModuleDetector = project.service()  

           private val FRIENDLY_IMPL_PATTERNS = listOf(  
            "friendlyImplementation",  
            "friendlyImplementation(",  
            "friendlyImplementation (",  
        )  
    }  
  public fun hasFriendlyImplementation(module: Module): Boolean {  
        val moduleName = module.name  

        // Check cache first  
        cache[moduleName]?.let { return it }  

        // Check if module name suggests it's a friend module (fast path)  
        if (moduleName.contains(".glue")) {  
            cache[moduleName] = true  
            return true        
            }  

        // Check the build file for friendlyImplementation  
        val hasFriendlyImpl = checkBuildFileForFriendlyImplementation(module)  
        cache[moduleName] = hasFriendlyImpl  

        return hasFriendlyImpl  
    }  

  private fun checkBuildFileForFriendlyImplementation(module: Module): Boolean {  
        val buildFile = GradleUtil.getGradleBuildScriptSource(module)  

        return buildFile != null && containsFriendlyImplementation(buildFile)  
    }  

private fun containsFriendlyImplementation(buildFile: VirtualFile): Boolean {  
        try {  
            val content = String(buildFile.contentsToByteArray())  

            return FRIENDLY_IMPL_PATTERNS.any { pattern ->  
                content.contains(pattern)  
            }  
        } catch (e: Exception) {  
            // If we can't read the file, assume no friendly implementation  
            return false  
        }  
    }  

   public fun clearCache() {  
        cache.clear()  
    }  

    public fun clearCacheForModule(moduleName: String) {  
            cache.remove(moduleName)  
        }  
    }

and lastly, a startup to listen for file changes on build files so we can invalidate our build file cache when a build file changes

public class FriendModuleStartupActivity : ProjectActivity {  

    override suspend fun execute(project: Project) {  
        // Register the build file listener  
        val connection = project.messageBus.connect()  
        connection.subscribe(  
            com.intellij.openapi.vfs.VirtualFileManager.VFS_CHANGES,  
            FriendModuleBuildFileListener(project),  
        )  
    }  
}
public class FriendModuleBuildFileListener(private val project: Project) : BulkFileListener {  

    override fun after(events: MutableList<out VFileEvent>) {  
        val detector = FriendModuleDetector.getInstance(project)  

        for (event in events) {  
            if (event is VFileContentChangeEvent) {  
                val file = event.file  
                if (isBuildFile(file)) {  
                    // Find the module for this build file and clear its cache  
                    val module = ModuleUtilCore.findModuleForFile(file, project)  
                    if (module != null) {  
                        detector.clearCacheForModule(module.name)  
                    }  
                }  
            }  
        }  
    }  

    private fun isBuildFile(file: VirtualFile): Boolean {  
        return file.name.endsWith(".gradle.kts")  
    }  
}

And after all of that, Android Studio no longer yells at us!

Outro

After adding support for a new Gradle configuration in our Gradle plugin and coercing Android Studio to not care about INVISIBLE_MEMBERS, we have a solution that allows wiring and glue modules to be friends where there is minimal impact to the developer experience!

I also later learned this concept of friendPaths is what allows test source sets to see internal members inside the main source set. I had sort of just taken this functionality for granted and never thought about how or why this works.

Lastly, if this 3 module system is anything you're interested in, I have a shameless plug for an IntelliJ plugin I maintain called Module Maker that helps streamline the creation of this 3 module systems: JetBrains Marketplace and Github and my blog post on the topic

Cover photo by fredrikwandem