Creating a Plugin for Android Studio (a complete walkthrough)
Here I am trying to implement the latest libs.versions.toml
in my application, but soon find myself exhausted from all those copy and paste and trying to figure out the name for versions, dependencies and plugins.
Wouldn’t it be nice if there’s a plugin for just this ? (if there’s one, please do let me know)
This is why I decided to write my own plugin and hopefully this could help anyone who wishes to do the same. Let’s get started.
Content
Here is a walkthrough on what we will cover in this article
- Selecting IntelliJ
- Creating New Project
- Create Actions
- Fetching Gradle Files
- Extract Info from Gradle Files
- Storing Info from Gradle Files
- Extract Info from libs.versions.toml
- Updating all Gradle Files
- Install your plugin
- GitHub
Selecting IntelliJ
In order to create our own plugin, we need the help from IntelliJ. However, you don’t simply download the latest version. Instead, you need to find the version that is compatible with your Android Studio.
If we go to AS > About Android Studio
, you should be able to see something like this :
From here, we know that we need to find the IntelliJ with a build number of 231.9392.1
. This number represents the Year.Major.Minor
or can be seen as the build was from 2023.1.
If we head to the download page of IntelliJ, we can find different builds in our year and month.
You can see the build number on the right. If that is not the build you are looking for, then simply click the dropdown on the screen and search for the one you need.
Creating a Project
After installing IntelliJ, you need to create a new project, but select IDE Plugin instead of the actual New Project.
You should see something similar to this at the end :
The plugin.xml is a Plugin Configuration File
Alternative
You can also use the template provided by IntelliJ on GitHub.
Create Actions
The next step is to create an Action.
An Action allows the plugin to add items to IntelliJ Platform-based IDE menus, such as File > Open File.
To create an Action, we need to implement an abstract class called AnAction.
The two main methods we typically override are :
// Updates the presentation of the action.
// This is called by beforeActionPerformedUpdate via UI Thread.
public void update(@NotNull AnActionEvent e) {}
// Performs the action logic on UI Thread.
public abstract void actionPerformed(@NotNull AnAction)
If you are targeting 2022.3 or later, you can also override this :
@Override
public @NotNull ActionUpdateThread getActionUpdateThread() {
if (this instanceof UpdateInBackground && ((UpdateInBackground)this).isUpdateInBackground()) {
return ActionUpdateThread.BGT;
}
if (updateNotOverridden()) {
return ActionUpdateThread.BGT;
}
return ActionUpdateThreadAware.super.getActionUpdateThread();
}
Please note that AnAction classes do not have class fields of any kind. This restriction prevents memory leaks. For more information about why, see Action Implementation.
First, let us
Here, I will override the actionPerformed
method to extract the project and display all the path of Gradle files in dialog.
class GenerateToml: AnAction() {
override fun actionPerformed(e: AnActionEvent) {
val gradleFiles = mutableListOf<String>()
e.project?.let { project ->
project.basePath?.let {basePath ->
// Use File.walk to reach all files
File(basePath).walk(FileWalkDirection.BOTTOM_UP).forEach {
if (it.isFile && it.name.contains(".gradle")) {
gradleFiles.add(it.canonicalPath)
}
}
}
val message = if (gradleFiles.isEmpty()) {
"Unable to find any gradle file"
} else {
var result = ""
gradleFiles.forEach {
result += it + "\n"
}
result
}
val title = "Please select files of interest" // e.presentation.description
Messages.showMessageDialog(
project,
message,
title,
Messages.getInformationIcon());
}
}
}
Then we can hover over to the class and select Register Action
.
Next, we need to select which group this action belongs to.
Here I selected ProjectViewPopupMenu
so the action can be shown if user decided to right-click on the project side-bar. Also, I placed the selection right before Bookmark
:
At the end, your plugin.xml
should has something like this :
<actions>
<action id="com.example.tomlgen.GenerateToml" class="com.example.tomlgen.GenerateToml" text="Generate toml"
description="YOUR DESC">
<add-to-group group-id="ProjectViewPopupMenu" anchor="after"
relative-to-action="ProjectViewPopupMenuRefactoringGroup"/>
<!--keyboard-shortcut keymap="$default" first-keystroke="E"/-->
</action>
If we go to Run
and run this plugin, a new IntelliJ will show up for testing. As you can see, when we go to the project side-bar and do a right-click on Windows or two-finger click on Mac, you can see our action :
Then if we click on it, it should show all the Gradle files :
Now that we know this works, we don’t need to show the component anymore, all we need to do is to read all Gradle files and get all dependencies, versions and plugins into libs.versions.toml
file.
If you wish to create UI components, feel free to go to IntelliJ Platform UI Guidelines and learn how and when to use different UI components.
Fetching Gradle Files
In order to extract dependencies and plugins from different Gradle files, we need to go through the project :
// e: AnActionEvent
e.project?.let {
project.basePath?.let {basePath ->
File(basePath).walk(FileWalkDirection.BOTTOM_UP).forEach {
if (it.isFile && it.name.contains(".gradle")) {
gradleFiles.add(it)
}
if (it.isFile && it.name == TOML_FILE_NAME) {
tomlFile = it
}
}
// clean up the log (delete the error.txt)
resetErrorLog(basePath)
}
}
After saving all files, we will do an asynchronous operation on all Gradle files to extract data from them :
val executors = Executors.newFixedThreadPool(gradleFiles.size)
val tasks = mutableListOf<Callable<Unit>>()
gradleFiles.forEach {file ->
// Extracting Dependencies and Plugins (#/GradleFileCount)
val content = file.readLines()
if (content.isNotEmpty()) {
val singleLine = content.reduce { acc, s -> "$acc\n$s" }
tasks.add(Callable<Unit> { extractDependenciesAndPlugins(singleLine, file) })
}
}
executors.invokeAll(tasks)
Here, I chose to use Executors.newFixedThreadPool
to prepare a thread per file. Then, tasks or Callable
will be invoked by the executor. Since all these tasks will be modifying some shared variables, we need to wait for all tasks to complete. This can be done via executors.invokeAll
, which will wait till all tasks to complete.
Executor achieve this by wrapping all Callable
with FutureTask
, a type of AbstractFuture
. When an AbstractFuture
is being fetched, it will go through a while(true)
loop until thread is being interrupted or when task has complete.
Let’s take a look at what extractDependenciesAndPlugins
does.
Extract Info
Set up Data Classes
extractDependenciesAndPlugins
consists of 3 steps :
- Extract Dependencies
- Extract Plugins
- Stores all Versions
A sealed class and 2 data classes are created to store information from Dependency
and Plugin
:
sealed class Types {
abstract fun getReplacementText(): String
abstract fun getTomlValue(): String
abstract fun getVersionKey(): String
}
Below you can see we extracted or generated the following variables from dependencies :
key
: the name of the dependency that will be used inlibs.versions.toml
config
: the Gradle Configuration used on this dependencygroup
,name
andversion
: the Group of dependency, iegroup:name:versions
originalStr
: the original string that we extract the data from.
data class Dependency(val key: String, val config: String, val group: String, val name: String, val version: String, val originalStr: String):Types() {
override fun getReplacementText(): String {
return "${config}(libs.${key.replace("-",".")})"
}
override fun getTomlValue(): String {
assert(group.isNotEmpty())
assert(name.isNotEmpty())
assert(version.isNotEmpty())
return "{group = \"${group}\", name = \"${name}\", version.ref = \"${getVersionKey()}\"}"
}
override fun getVersionKey(): String {
return key.split("-").toVersionKey(false)
}
override fun equals(other: Any?): Boolean {
return other?.let { o->
(o as? Dependency)?.let {
it.config == config && it.group == group && it.name == name && it.version == version
} ?: false
} ?: false
}
override fun hashCode(): Int {
var result = config.hashCode()
result = 31 * result + group.hashCode()
result = 31 * result + name.hashCode()
result = 31 * result + version.hashCode()
return result
}
}
Similarly for Plugin
:
data class Plugin (val key: String, val group: String, val version: String, val originalStr: String): Types() {
override fun getReplacementText(): String {
return "alias(libs.plugins.${key.replace("-",".")})"
}
override fun getTomlValue(): String {
assert(version.isNotEmpty())
return "{id = \"${group}\", version.ref = \"${getVersionKey()}\"}"
}
override fun getVersionKey(): String {
return key.split("-").toVersionKey(true)
}
override fun equals(other: Any?): Boolean {
return other?.let { o->
(o as? Plugin)?.let {
it.group == group && it.version == version
} ?: false
} ?: false
}
override fun hashCode(): Int {
var result = group.hashCode()
result = 31 * result + version.hashCode()
return result
}
}
Prepare Regex
In order to locate all the data we are interested in, we can either search using special words or using regular expression, and of course we will choose the latter.
Here are the regular expression used to find all the data we need :
/**
These will find dependencies {} and plugins {} blocks
*/
const val DEPENDENCIES_SCOPE_REGEX = "dependencies\\s*\\{([\\S\\s]*?)\\s*}"
const val PLUGINS_SCOPE_REGEX = "plugins\\s*\\{([\\S\\s]*?)\\s*}"
// This will find config("your dependency") or config(group= ..., name= ..., version=...)
const val DEPENDENCY_WITH_CONFIG_REGEX = "(\\w*)\\s*\\(\\s*([^)]*)\\s*\\)"
// This will further extract group:name:version from dependency string
const val GROUP_NAME_VERSION_REGEX = "([\\w.-]+):([\\w.-]+):([\\d.]+)"
// This will extract group = "", name = "", version = "" patterns
const val NAME_EQUAL_STRING_VALUE_REGEX = "([\\w.-]+)\\s*=\\s*\"([^\"]*)\""
// As for plugin, we will only extract plugin with version
// ie id("group:name") version "my version"
const val PLUGINS_WITH_VERSION_REGEX = "[/ *]*id\\s*\\(\\s*\"([^\"]+)\"\\s*\\)(\\s*version\\s*\"([\\d.]+)\\s*\")"
Other than the regular expressions mentioned above, there are also other expressions used in this project and I will post them here for future references :
// This will be used to find dependencies and plugins stored in the original libs.versions.toml file
const val TOML_DEP_PLUGIN_REGEX = "([\\w-]+)\\s*=\\s*(\\{[^}]*})"
// This will simply recognize values between quotes
const val QUOTED_REGEX = "\"([^\"]*)\""
// This will recognize Top Level Domain (TLD) ie com. , org. , tw. ...
// this will be used when removing TLD from group string
const val TLD_REGEX = "^\\w{1,3}[.]"
// This will recognize the section defined in TOML ie [versions], [plugins] and [libraries]
const val TOML_SEC_REGEX = "\\[(\\w+)]([^\\[\\]]*)"
Extracting Dependencies and Plugins
The code to extract dependencies is quite lengthy but straight forward. It will extract data from two format :
configuration ("group:name:version")
configuration (group = "group", name = "name", version = "version")
private fun extractDependencies(dependenciesStr: String): List<Types.Dependency> {
val allDependencies = mutableListOf<Types.Dependency>()
if (dependenciesStr.isEmpty()) return allDependencies
val groupNameVersionRegex = Regex(GROUP_NAME_VERSION_REGEX)
val nameEqualStringValueRegex = Regex(NAME_EQUAL_STRING_VALUE_REGEX)
val quotationValueRegex = Regex(QUOTED_REGEX)
val dependencies = Regex(DEPENDENCY_WITH_CONFIG_REGEX).findAll(dependenciesStr)
dependencies.forEach {
// all these will match with 3 parts
// 1. configuation (
// 2. whatever before the first ')'
var group = ""
var name = ""
var version = ""
val key: String
val config = it.groups[1]?.value ?: ""
val content = it.groups[2]?.value ?: ""
val originalStr = it.groups[0]?.value ?: ""
if (config.isNotEmpty() && content.isNotEmpty()) {
if (!content.contains("(")) {
// make sure there's no other configurations or functions within
// fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"
// project(":mylibrary"
if (content.contains(":")) {
// this should be {group} : {name} : {version}
val groupNameVersion = groupNameVersionRegex.find(content)?.value?.split(":") ?: listOf()
if (groupNameVersion.size == 3) {
group = groupNameVersion[0]
name = groupNameVersion[1]
version = groupNameVersion[2]
if (group.isNotEmpty() && name.isNotEmpty() && version.isNotEmpty()) {
key = generateKeyWith(group, name)
allDependencies.add(Types.Dependency(key, config, group, name, version, originalStr))
}
}
} else if (content.contains(GROUP)) {
// this should be group= ... name = ... version = ...
val results = nameEqualStringValueRegex.findAll(content)
if (results.count() == 3) {
results.forEach {matchResult ->
val str = matchResult.value
if (str.contains(GROUP)) {
group = quotationValueRegex.find(str)?.groups?.get(1)?.value ?: ""
} else if (str.contains(NAME)) {
name = quotationValueRegex.find(str)?.groups?.get(1)?.value ?: ""
} else if (str.contains(VERSION)) {
version = quotationValueRegex.find(str)?.groups?.get(1)?.value ?: ""
}
}
if (group.isNotEmpty() && name.isNotEmpty() && version.isNotEmpty()) {
key = generateKeyWith(group, name)
allDependencies.add(Types.Dependency(key, config, group, name, version, originalStr))
}
}
}
}
}
}
return allDependencies
}
Similar extraction strategy is used for Plugin :
private fun extractPlugins(pluginsStr: String): List<Types.Plugin> {
val finalPlugins = mutableListOf<Types.Plugin>()
PLUGINS_WITH_VERSION_REGEX.toRegex().findAll(pluginsStr).forEach { matchResult ->
val originalStr = matchResult.groups[0]?.value ?: ""
// make sure this is not commented out
if (!originalStr.contains("//") && !originalStr.contains("/*")) {
when (matchResult.groups.size) {
4 -> {
val group = matchResult.groups[1]?.value ?: ""
val version = matchResult.groups[3]?.value ?: ""
val key = generateKeyWith(group, null)
finalPlugins.add(Types.Plugin(key, group, version, originalStr))
}
}
}
}
return finalPlugins
}
To reach these two functions, we can call extractDependenciesAndPlugins
to do so :
private fun extractDependenciesAndPlugins(singleLine: String, file: File) {
val dependencyMap = mutableMapOf<String, String>()
val pluginMap = mutableMapOf<String, String>()
val dependenciesFound = extractDependencies(DEPENDENCIES_SCOPE_REGEX.toRegex().find(singleLine)?.value ?: "")
dependenciesFound.forEach {
dependencyMap[it.originalStr] = it.getReplacementText()
}
updateDependencies(dependenciesFound)
val pluginsFound = extractPlugins(PLUGINS_SCOPE_REGEX.toRegex().find(singleLine)?.value ?: "")
pluginsFound.forEach {
pluginMap[it.originalStr] = it.getReplacementText()
}
updatePlugins(pluginsFound)
dataForFiles[file] = TypeBundle(dependencyMap, pluginMap)
mutableListOf<Types>().apply {
addAll(dependenciesFound)
addAll(pluginsFound)
updateVersions(this)
}
}
Storing Info from Gradle File
As you can see from extractDependenciesAndPlugins
, we call updatePlugins
and updateDependencies
to store data from all Gradle files.
In them, we add data to 4 different dictionaries :
private val dependenciesForToml = mutableMapOf<String, String>()
private val pluginsForToml = mutableMapOf<String, String>()
private val versionsForToml = mutableMapOf<String, String>()
private val dataForFiles = ConcurrentHashMap<File, TypeBundle>()
dataForFiles
is a HashMap that is thread safe, so we don’t need to worry about race condition for it. Also, since it is using File as the key, I am sure that there will only be TypeBundle per File.
Why did I choose ConcurrentHashMap ?
To be honest, I just want to test it out.
The main purpose of dataForFiles
is to store the original string from the Gradle file and the final string after libs.versions.toml
is created.
data class TypeBundle(val dependencies: Map<String, String>, val plugins: Map<String, String>)
On the other hand, the other dictionaries will responsible for storing the key and value that will be used to write into libs.versions.toml
:
key = value #like this
Since they only have one variable for different components, we need to take care of race condition. This is done using mutex that comes along with all Java objects. Here, we will need to create the locks :
private val lock1 = Any()
private val lock2 = Any()
private val lock3 = Any()
Then upon calling the updating methods, we need to add @Synchronized
annotation to those functions :
@Synchronized
private fun updateDependencies(dependencies: List<Types.Dependency>) {
synchronized(lock1) {
dependencies.forEach {
dependenciesForToml[it.key] = it.getTomlValue()
}
}
}
@Synchronized
private fun updatePlugins(plugins: List<Types.Plugin>) {
synchronized(lock2) {
plugins.forEach {
pluginsForToml[it.key] = it.getTomlValue()
}
}
}
Similarly for updateVersions
:
@Synchronized
private fun updateVersions(types: List<Types>) {
synchronized(lock3) {
types.forEach {type ->
when(type) {
is Types.Dependency -> {
if (type.version.isNotEmpty()) {
if (versionsForToml.contains(type.getVersionKey()) && versionsForToml[type.getVersionKey()] != type.version) {
versionsForToml[type.getVersionKey() + "-" + type.version.replace(".", "-")] = type.version
} else {
versionsForToml[type.getVersionKey()] = type.version
}
}
}
is Types.Plugin -> {
// ... do the same as Types.Dependency
}
}
}
}
}
So far, we have successfully fetched all the dependencies, plugins and versions from the info inside Gradle files.
However, to proceed further, we need to make sure we keep the data that was already inside libs.versions.toml
, if there is one.
Extracting Info from libs.versions.toml
To extract [versions]
, [libraries]
and [plugins]
within libs.versions.toml
, we need to use the regular expression we had before. Once each section was found, the data will be captured and stored :
if (tomlFile != null) {
progressIndicator.text = "Extracting Data from libs.versions.toml."
progressIndicator.fraction = (STEP_EXTRACT_TOML.toDouble() / TOTAL_STEPS.toDouble())
Thread.sleep(500)
// read toml File
tomlFile?.let {
val content = it.readLines()
var singleLine = ""
if (content.isNotEmpty()) {
singleLine = content.reduce {acc, str -> if (acc.contains(STATEMENT)) str else "$acc\n$str" }
TOML_SEC_REGEX.toRegex().findAll(singleLine).forEach {section ->
// each section
val sec = section.groups[1]?.value ?: ""
val fullContent = section.groups[2]?.value ?: ""
var key: String
var value: String
singleLine = singleLine.replace(section.groups[0]?.value ?: "", "")
when(sec) {
VERSIONS -> {
NAME_EQUAL_STRING_VALUE_REGEX.toRegex().findAll(fullContent).forEach {match ->
key = match.groups[1]?.value ?: ""
value = match.groups[2]?.value ?: ""
if (key.isNotEmpty() && value.isNotEmpty() && !versionsForToml.contains(key)) {
versionsForToml[key] = value
}
}
}
LIBRARIES -> {
TOML_DEP_PLUGIN_REGEX.toRegex().findAll(fullContent).forEach { depMatch ->
key = depMatch.groups[1]?.value ?: ""
value = depMatch.groups[2]?.value ?: ""
// extracts all group, name, version
if (key.isNotEmpty() && value.isNotEmpty()) {
if (!dependenciesForToml.contains(key)) {
dependenciesForToml[key] = value
}
}
}
}
PLUGINS -> {
TOML_DEP_PLUGIN_REGEX.toRegex().findAll(fullContent).forEach { depMatch ->
key = depMatch.groups[1]?.value ?: ""
value = depMatch.groups[2]?.value ?: ""
// extracts all group, name, version
if (key.isNotEmpty() && value.isNotEmpty()) {
if (!pluginsForToml.contains(key)) {
pluginsForToml[key] = value
}
}
}
}
}
}
}
remainingTomlStr = singleLine
}
}
Write into libs.versions.toml file
With all info we need, we can now right data into libs.versions.toml
. However, just to be save, I stored them in a temporary file then transfer these info into libs.versions.toml
.
I think I might overdone it this time, perhaps storing into
libs.versions.toml
directly is just fine.
val versionKeys = versionsForToml.keys.sorted()
val sortedValidDependencies = dependenciesForToml.keys.sorted()
val sortedValidPlugins = pluginsForToml.keys.sorted()
// Write versions, dependencies and plugins into a temp file
project.basePath?.let {basePath ->
progressIndicator.text = "Storing Data into Temporary TOML file."
progressIndicator.fraction = (STEP_WRITE_IN_TEMP_TOML.toDouble() / TOTAL_STEPS.toDouble())
Thread.sleep(500)
// create a temp libs.versions.toml file
tempTomlFile = File("$basePath/gradle/libs.versions.temp.toml")
try {
// write all into temp file
var writer = FileWriter(tempTomlFile!!)
val strBuilder = StringBuilder()
val dateStr = LocalDateTime.now().format(DateTimeFormatter.ofPattern(DATE_TIME_PATTERN))
strBuilder.append("$STATEMENT $dateStr\n\n")
// write version
strBuilder.append("[$VERSIONS]\n")
versionKeys.forEach {key ->
strBuilder.append("$key = \"${versionsForToml[key]}\"\n")
}
// write dependencies
strBuilder.append("\n[$LIBRARIES]\n")
sortedValidDependencies.forEach {key ->
// {group = "androidx.core", name = "core-ktx", version.ref ="androidxCore"}
strBuilder.append("$key = ${dependenciesForToml[key]}\n")
}
// write plugins
strBuilder.append("\n[$PLUGINS]\n")
sortedValidPlugins.forEach {key ->
// android-application = {id = "com.android.application", version.ref="androidApplication"}
strBuilder.append("$key = ${pluginsForToml[key]}\n")
}
strBuilder.append("\n")
strBuilder.append(remainingTomlStr)
writer.write(strBuilder.toString())
writer.close()
progressIndicator.text = "Storing Data into libs.versions.toml."
progressIndicator.fraction = (STEP_WRITE_IN_TOML.toDouble() / TOTAL_STEPS.toDouble())
Thread.sleep(500)
writer = FileWriter("$basePath/gradle/libs.versions.toml")
writer.write(strBuilder.toString())
writer.close()
progressIndicator.text = "Cleaning Up"
progressIndicator.fraction = (STEP_CLEAN_UP.toDouble() / TOTAL_STEPS.toDouble())
Thread.sleep(500)
tempTomlFile?.delete()
tasks.clear()
// ======== update files ======== (next part)
} catch (e: Exception) {
// remove tomlFile and prompt error
// create a generatetoml_log.txt to store error logs
try {
tempTomlFile?.delete()
storesError(basePath, "[creating toml]\n\t" + e.localizedMessage)
indicator.stop()
} catch (e: Exception) {/*do nothing*/}
}
}
This is quite straight forward, so let’s move on to the last step.
Update Gradle Files
Finally, we will go through all Gradle files and update them using the values within dataForFiles
and the key to locate the file :
progressIndicator.text = "Update All Gradle Files."
progressIndicator.fraction = (STEP_UPDATE.toDouble() / TOTAL_STEPS.toDouble())
Thread.sleep(500)
dataForFiles.forEach {
tasks.add(Callable<Unit> {
updateFile(it.key, it.value.dependencies, it.value.plugins, basePath)
})
}
executors.invokeAll(tasks)
progressIndicator.text = "Done"
progressIndicator.fraction = 1.toDouble()
progressIndicator.stop()
Thread.sleep(500)
And that’s how you make this :
Look at the bottom, you see Cleaning Up
? That’s what the ProgressIndicator is doing.
And the reason why we need to add Thread.sleep(500)
? Well, according to this , apparently if the process is completed less than 500 ms, the indicator will not be displayed. So to let user know what is the current progress, I added some time and make indicator show up.
Install your Plugin
Now that everything is ready, we can also test run it in Android Studio prior publishing it.
All we need to do is in the terminal, go to your plugin file and type :
./gradlew buildPlugin
With this Task, Gradle will create a zip file in build > distributions
folder.
Then, in Android Studio, go to Settings > Plugins :
Click on the setting icon and select Install Plugin from Disk
:
Then all you need to do is select the zip file created, restart the IDE and everything should work as expected.
GitHub
(update 12/29/2023) The GitHub is now open for public. Just so you know, there are still some small details that needs to be fixed, but it is a functional plugin. Feel free to try it out and let me know your thought.
PS : It takes some time for
libs.versions.toml
to show up, I will try to figure out what happened.
If you enjoy this medium, please give me applause.
If there’s any suggestions or questions, please leave a message and I’ll get back to you.
Enjoy.