Creating a Plugin for Android Studio (a complete walkthrough)

Jimmy Liu
Kuo’s Funhouse
Published in
14 min readDec 28, 2023

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

  1. Selecting IntelliJ
  2. Creating New Project
  3. Create Actions
  4. Fetching Gradle Files
  5. Extract Info from Gradle Files
  6. Storing Info from Gradle Files
  7. Extract Info from libs.versions.toml
  8. Updating all Gradle Files
  9. Install your plugin
  10. 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.

https://www.jetbrains.com/idea/download/other.html

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 :

If you want to customize your plugin icon, you can change the pluginicon.svg.

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 .

Hover over your action and select Register Action to register (this photo was taken before any changes, thus different name is shown)

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 :

  1. Extract Dependencies
  2. Extract Plugins
  3. 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 in libs.versions.toml
  • config : the Gradle Configuration used on this dependency
  • group , name and version : the Group of dependency, ie group: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 :

  1. configuration ("group:name:version")
  2. 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.

References

  1. Building a plugin for Android Studio #1: Introduction and tools
  2. Android Studio Plugin Development
  3. Write an Android Studio Plugin Part 1: Creating a basic plugin by Marcos Holgado

--

--

Jimmy Liu
Kuo’s Funhouse

App Developer who enjoy learning from the ground up.