How To Win Friends And Influence Internal Visibility

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.
A public facing API module
An internal implementation module
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



