7

Gradle Plugin Tutorial for Android: Getting Started

 3 years ago
source link: https://www.raywenderlich.com/22198417-gradle-plugin-tutorial-for-android-getting-started
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
Home Android & Kotlin Tutorials

Gradle Plugin Tutorial for Android: Getting Started

Learn how to create a Gradle plugin within your existing Android app, or as a standalone project that you can publish and use in any Android project.

By Bhavesh Misri Jun 7 2021 · Article (25 mins) · Intermediate

5/5 2 Ratings

Version

Wouldn’t it be awesome if — by typing just one command or clicking a button — Android Studio automatically built a signed APK for you, uploaded it to Google Play Store, and updated you on your favorite platform? While you could achieve all of this by writing custom tasks for each function, what if you want to achieve the same features among multiple modules or projects? This is where writing a custom Gradle plugin comes in handy.

In this tutorial, you’ll be writing your own Gradle plugin. This plugin will change the name of the APK, move the APK to your desired location, and create a text file in which it’ll add all the dependencies for all the APKs you’ve created through the plugin.

In the process, you’ll learn about the following topics:

  • Gradle tasks
  • Gradle plugins
  • Different ways to package a plugin
  • Publishing a plugin locally
Note: This tutorial assumes you’re familiar with Kotlin and Android development. If you’re a beginner, check out our Beginning Android Development with Kotlin tutorial first.

For an introduction to the Gradle build system, check out Gradle Tutorial for Android: Getting Started.

Getting Started

Download the materials using the Download Materials button at the top or the bottom of this page. Extract and open the starter project within the directory named ProjectTracker in Android Studio.

Build and run. You’ll see something like this:

ProjectTracker app

This is a pretty simple and straightforward app. When a user enters anything in EditText and taps the Enter Value Button, the text will appear on the screen. That’s it!

You don’t have to worry about what’s happening in the app, as you’ll be working in the buildSrc directory and with the build.gradle files.

But before you jump into the code and get your hands dirty, it’s important to understand what Gradle is and how you can create plugins with it.

What Is Gradle?

Gradle is an open-source build automation system that helps you manipulate the build process and its logic. For example, when you build an app, it’s Gradle that compiles all the code and creates an APK for you. In this tutorial, you’ll manipulate this build process and customize it to your needs.

Gradle Tasks

Gradle has tasks, and each one of them represents a single atomic piece of work for a build. You can either create a custom task, or you can create a plugin that can consist of multiple tasks.

To see the list of all the tasks, run ./gradlew tasks in the terminal from the project’s root directory. This will print all the tasks:

Gradle tasks

You can write your own task by going into the module-level build.gradle file. This is the one found inside the app directory. Open it and add the following after plugins block:

task printSomething() {
  doLast {
    println("Welcome to gradle world!")   
  }
}

To execute the task you created, in the terminal, run ./gradlew -q printSomething. The -q command-line option suppresses Gradle’s log messages so that only the output of the tasks is shown:

$ ./gradlew -q printSomething
Welcome to gradle world!

The doLast method is important here, as it tells Gradle to only execute the action when the task is called. Without it, you’ll be executing the action at the configuration time on every build. In fact, there’s also a doFirst method that can be overridden. So, if you have:

task printSomething() {
  println("Before doFirst!")

  doFirst {
    println("In doFirst!")   
  }

  println("In between doFirst and doLast!")

  doLast {
    println("In doLast!")   
  }

  print("After doLast!")
}

Then after running ./gradlew printSomething, the output would be:

$ ./gradlew printSomething

> Configure project :
Before doFirst!
In between doFirst and doLast!
After doLast!
> Task :printSomething
In doFirst!
In doLast!

BUILD SUCCESSFUL in 8s
1 actionable task: 1 executed

The benefit of having doFirst and doLast is it allows for extending tasks and performing functionality at specific parts of the task’s lifecycle.

Packaging the Plugin

So far, you know what Gradle is, what Gradle tasks are, and how to see all the Gradle tasks. You also created your own task and executed it. The next thing on the list is the Gradle plugin. A Gradle plugin is nothing more than a combination of tasks that you’d like to execute together. There are a few ways you can write your Gradle plugin, so this next section will go through each of them.

Build script
Put the source of the plugin directly into the build.gradle file. One big advantage of this approach is that the class is automatically compiled and added to the classpath of the build script without you configuring anything. The disadvantage is that the plugin cannot be used in another module.

buildSrc project
Another approach is to place the source of the plugin in the rootProjectDir/buildSrc/src/main/java directory. The benefit of doing this is that the plugin is usable throughout the project and not confined to one module. However, you need to add a few more files to make it work with this approach, and the plugin isn’t usable outside the project.

Standalone project
You can also create a separate project for the plugin. The benefit is that after building the project, a JAR file is generated, and this may be used in any of your Android Studio projects. The disadvantage of this approach is that you need to use a separate IDE like IntelliJ — and not Android Studio — to create a Gradle plugin project.

In these examples, you’ll start with the plugin in the build script, which will keep things simple. Then you’ll look at creating a buildSrc project and finally a standalone project.

Creating a Plugin in the Build Script

Delete the printSomething task you created earlier, as you’ll be creating a plugin that will perform the same task.

To create a Gradle plugin in the build script, add the following lines at the end of the build.gradle file:

class PrintClass implements Plugin<Project> {
  @Override
  void apply(Project project) {
    project.task("printSomething") {
      doLast {
        println("Welcome from gradle world!")
      }
    }
  }
}

You’re creating a class that implements the Plugin class, which is a generic class, and it takes Project as a type parameter. This will call the plugin class’s apply method, and the method will have the Project type as a parameter. You can use this to configure the project however you need to. Inside apply, write your task using the project parameter.

Your plugin is ready, but you haven’t yet applied it to your project. For that, write the following at the end of your build.gradle file:

apply plugin: PrintClass

Now you can execute ./gradlew -q printSomething in the terminal and see the same output.

Congrats! You created your first plugin, and you can even call the task from the terminal :]. But this plugin only prints a sentence, and it doesn’t configure your project in any way. On top of that, you can only use it in one module. Undo the changes made in this section and read ahead to learn how to create a plugin using the buildSrc directory.

Creating a Plugin Using the buildSrc Directory

Now you’ll create a plugin in a different way. First, create a directory, buildSrc, at the project’s root. This is where you’ll add your build logic, and as it supports Kotlin DSL, you can write the plugin in Kotlin.

Note: If you’re unfamiliar with the concept of buildSrc and how to use it, check out the Gradle Tips and Tricks for Android tutorial.

Inside the new directory, create a file, build.gradle.kts. Inside the file, add the following code:

plugins {
  `kotlin-dsl`
}

repositories {
  mavenCentral()
}

So, what’s happening here? Well, first of all buildSrc is a directory that Gradle looks at while compiling. If it finds any custom build code, it adds that to Gradle’s classpath.

In this file, you’re applying the kotlin-dsl plugin, but if you try to sync the project only by adding this, it won’t work. You need to add mavenCentral() in repositories as well, because the plugin is hosted there. After you’ve added these lines, sync the project and you’ll see a few more directories in the buildSrc folder. Your project structure will look something like this:

Project structure

This means your build.gradle.kts file was successfully added to Gradle’s classpath. Next, right-click on the buildSrc folder and select NewDirectory, and then select src/main/java.

Here, you can start writing your plugins in Kotlin and all the modules can access the plugin. Open the java folder and create a Kotlin class, BuildManager, that implements Plugin:

import org.gradle.api.Plugin
import org.gradle.api.Project

class BuildManager : Plugin<Project> {
  override fun apply(project: Project) {
  }
}

You can create tasks within this apply function in a way similar to how you did in your module level build.gradle file earlier. For this, you’ll create a task which will do a bit more than printing a sentence.

Inside apply, include the following code:

project.task("renameApk") {
 doLast {
   val apkPath = "/outputs/apk/release/app-release-unsigned.apk"
   val renamedApkPath = "/outputs/apk/release/RenamedAPK.apk"
   val previousApkPath = "${project.buildDir.absoluteFile}$apkPath"
   val newPath = File(previousApkPath)

   if (newPath.exists()) {
     val newApkName = "${project.buildDir.absoluteFile}$renamedApkPath"
       newPath.renameTo(File(newApkName))
   } else {
     println("Path does not exist!")
   }
 }
}.dependsOn("build")

Add an import for the File class at the top of the file:

import java.io.File

You’re creating a task called renameApk, and in that class, you’re finding where your APK is located and then renaming it. But what if the APK hasn’t yet been created? Or, what if you removed the file for some reason? Well, that’s where the last line, .dependsOn("build"), comes to the rescue. This function will create a dependency on the build task that will create the APK.

But if you try to execute the task from the terminal, it’ll fail and give you an error saying BUILD FAILED. This is because you havn’t yet applied the plugin to your project. To do that, go into your build.gradle.kts file and add the following:

gradlePlugin {
  plugins {
    create("BuildManager") {
      id = "com.raywenderlich.plugin"
      implementationClass = "BuildManager"
      version = "1.0.0"
    }
  }
}

In the code above, you’re registering your plugin by using one of the extension functions, gradlePlugin. By using the create function, you can give a name, ID, version, and reference of the plugin class you created.

Now it’s time to add the registered plugin to your module. Jump into your module-level build.gradle file and add it as the last item in the plugins task:

plugins {
  ...
  id 'com.raywenderlich.plugin'
}

You’re ready to go! Sync the project, run ./gradlew clean to clean up the build directory and then execute your task with ./gradlew -q renameApk. Go to the /app/build/outputs/apk/release folder and find your renamed APK.

Dependency Management

Before adding more features to your plugin, you’ll clean up the project a bit. Create a new Kotlin file in your java folder and name it Constant. Add the following code in the file:

import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

object Dependencies {
  const val kotlinCore = "androidx.core:core-ktx:${Versions.kotlinCoreVersion}"
  const val appCompat = "androidx.appcompat:appcompat:${Versions.appCompatVersion}"
  const val material = "com.google.android.material:material:${Versions.materialVersion}"
  const val constraint = "androidx.constraintlayout:constraintlayout:${Versions.constraintVersion}"
  const val jUnit = "junit:junit:${Versions.jUnitVersion}"
  const val jUnitExt = "androidx.test.ext:junit:${Versions.jUnitExtVersion}"
  const val espresso = "androidx.test.espresso:espresso-core:${Versions.espressoVersion}"

  const val kotlinStdLib = "org.jetbrains.kotlin:kotlin-stdlib:${Versions.kotlinStdLibVersion}"
  const val kotlinGradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.kotlinStdLibVersion}"
  const val buildToolGradle = "com.android.tools.build:gradle:${Versions.buildToolGradleVersion}"
}

object Versions {
  const val compileSdkVersion = 30
  const val buildToolsVersion = "30.0.3"
  const val minSdkVersion = 21
  const val targetSdkVersion = 30

  const val kotlinStdLibVersion = "1.4.32"
  const val buildToolGradleVersion = "4.1.3"

  const val kotlinCoreVersion = "1.3.2"
  const val appCompatVersion = "1.2.0"
  const val materialVersion = "1.3.0"
  const val constraintVersion = "2.0.4"
  const val jUnitVersion = "4.13.2"
  const val jUnitExtVersion = "1.1.2"
  const val espressoVersion = "3.3.0"
}

object AppDetail {
  const val applicationId = "com.raywenderlich.projecttracker"
  const val appName = "ProjectTracker"
  const val versionCode = 1
  const val versionName = "1.0.0"

  // Change these values as needed
  const val previousPath = "/outputs/apk/release"
  const val targetPath = "newOutput"

  const val previousName = "app-release-unsigned.apk"
  val newApkName = "$appName-${getDate(false)}($versionCode).apk"

  const val dependencyFileName = "Dependencies.txt"
}


fun getDate(forTxtFile: Boolean): String {
  val current = LocalDateTime.now()
  val formatter = if (forTxtFile) {
    DateTimeFormatter.ofPattern("dd MMM, yyy")
  } else {
    DateTimeFormatter.ofPattern("yyyyMMdd")
  }
  return current.format(formatter)
}

The Dependencies, Version and AppDetail classes will be used to manage dependencies, and you’ll also be using the AppDetail class and getDate() function to add more functionality to your plugin. All these do at the moment is to hold the build information in classes. Next, you can jump into the Gradle files of your project and update your dependencies to make it look cleaner and to manage them better.

After the changes, your module-level build.gradle file will look like this:

plugins {
  id 'com.android.application'
  id 'kotlin-android'
  id 'com.raywenderlich.plugin'
}

android {
  compileSdkVersion Versions.compileSdkVersion
  buildToolsVersion Versions.buildToolsVersion

  defaultConfig {
    applicationId AppDetail.applicationId
    minSdkVersion Versions.minSdkVersion
    targetSdkVersion Versions.targetSdkVersion
    versionCode AppDetail.versionCode
    versionName AppDetail.versionName

    testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
  }

  buildTypes {
    release {
      minifyEnabled false
      proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
    }
  }
  compileOptions {
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
  }
  kotlinOptions {
    jvmTarget = '1.8'
  }
}

dependencies {
  implementation Dependencies.kotlinStdLib
  implementation Dependencies.kotlinCore
  implementation Dependencies.appCompat
  implementation Dependencies.material
  implementation Dependencies.constraint
  testImplementation Dependencies.jUnit
  androidTestImplementation Dependencies.jUnitExt
  androidTestImplementation Dependencies.espresso
}

And your top-level or project-level build.gradle file will look like this:

buildscript {
  repositories {
    google()
    mavenCentral()
  }
  dependencies {
    classpath Dependencies.buildToolGradle
    classpath Dependencies.kotlinGradlePlugin
  }
}

allprojects {
  repositories {
    google()
    mavenCentral()
  }
}

task clean(type: Delete) {
  delete rootProject.buildDir
}

With that, it’s time to add more tasks to the plugin class.

Adding More Tasks

You can create more tasks in the apply function the same way you created renameApk, but it’s better to separate tasks into different classes to keep things clean and reusable.

Create a folder inside the java folder and name it tasks. Inside the tasks folder, create a new Kotlin class named ManageApk.

In the ManageApk class, add the following code:

package tasks

import AppDetail.newApkName
import AppDetail.previousName
import AppDetail.previousPath
import AppDetail.targetPath
import org.gradle.api.DefaultTask
import org.gradle.api.tasks.TaskAction
import java.io.File

open class ManageApk: DefaultTask() {
  @TaskAction
  fun renameApk() {
    val newPath = File("${project.buildDir.absoluteFile}/$previousPath/$previousName")
    if (newPath.exists()) {
      val newApkName = "${project.buildDir.absoluteFile}/$previousPath/$newApkName"
      newPath.renameTo(File(newApkName))
    } else {
      println("Path not exist!")
    }
    moveFile()
  }

  private fun moveFile() {
    File("${project.buildDir.absoluteFile}/$previousPath/$newApkName").let { sourceFile ->
      try {
        sourceFile.copyTo(File("$targetPath/$newApkName"))
      } catch (e: Exception) {
        e.printStackTrace()
        val folder = File(targetPath)
        folder.mkdir()
      } finally {
        sourceFile.delete()
      }
    }
  }
}

By extending the class with DefaultTask, you have the ability to define your own tasks. To create your own task, you need to annotate the function with @TaskAction and then write your logic inside it. The moveFile function will move the APK created to your desired location (make sure to change the location inside Constant.kt). Your task class is ready now.

To use the task in your plugin, open your BuildManager plugin class, and inside apply(), replace the existing code with:

project.tasks.register<ManageApk>("renameApk") {
  dependsOn("build")
}

Add the following imports as well:

import org.gradle.kotlin.dsl.register
import tasks.ManageApk

You’ve now registered the task you created in your ManageApk class in your plugin class. The task will be executed when renameApk is called, the latter of which depends on build task.

Now, sync and run ./gradlew clean to clean the project. In your terminal, run ./gradlew -q renameApk, and your APK will be renamed to whatever name you gave the app in Constant.kt. Its suffix should be today’s date and the version code you provided. It’ll also move the APK to your desired location.

Now you’ll make the final changes. Create ManageDependency.kt inside the tasks directory and add the following code:

package tasks

import AppDetail.dependencyFileName
import AppDetail.targetPath
import getDate
import org.gradle.api.DefaultTask
import org.gradle.api.tasks.TaskAction
import java.io.File

open class ManageDependency : DefaultTask() {
  @TaskAction
  fun saveDependencies() {
    var file = File("$targetPath/$dependencyFileName")
    if (!file.exists()) {
      println("Path does not exist. Creating folder...")

      val folder = File(targetPath)
      if (!folder.exists()) {
        folder.mkdir()
      }
        
      file = File(targetPath, dependencyFileName)
      file.createNewFile()
    }
    writeDependencies(file)
  }

  private fun writeDependencies(file: File) {
    file.appendText(getDate(true), Charsets.UTF_8)
    file.appendText(
        "\n${AppDetail.appName} - ${AppDetail.versionName}(${AppDetail.versionCode})\n",
        Charsets.UTF_8
    )
    file.appendText("====Dependencies====\n", Charsets.UTF_8)

    project.configurations.asMap.forEach {
      if (it.key.contains("implementation")) {
        it.value.dependencies.forEach { dependency ->
          file.appendText(
              "${dependency.group}:${dependency.name}:${dependency.version}\n",
              Charsets.UTF_8
          )
        }
      }
    }

    file.appendText("====================", Charsets.UTF_8)
    file.appendText("\n\n\n", Charsets.UTF_8)
  }
}

These tasks will create a text file and add all the dependencies of your module in the file every time you create a build. But for that, you need to add these tasks in your plugin class. Open your BuildManager plugin class and add the following lines inside the apply function:

project.tasks.register<ManageDependency>("saveDependencies") {
  dependsOn("renameApk")
}

project.task("createBuild").dependsOn("saveDependencies")

Add the import statement as follows:

import tasks.ManageDependency

Your complete BuildManager class will look like this:

import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.invoke
import org.gradle.kotlin.dsl.register
import task.ManageApk
import task.ManageDependency

class BuildManager : Plugin<Project> {
  override fun apply(project: Project) {
    project.tasks.register<ManageApk>("renameApk") {
      dependsOn("build")
    }

    project.tasks.register<ManageDependency>("saveDependencies") {
      dependsOn("renameApk")
    }

    project.task("createBuild").dependsOn("saveDependencies")
  }
}

Now you can test the plugin you created. Firstly, delete the newOutput folder. Run ./gradlew clean to delete the build directory, and then run ./gradlew -q createBuild in the terminal to test the plugin.

That’s it for creating a Gradle plugin in buildSrc!! Finally, you’ll learn how to create the plugin as a standalone project in the next section.

Creating a Standalone Plugin Project

For the standalone project, you’ll keep things simpler and create a plugin that only creates a text file, and then add the dependencies of the project in that file. Creating a standalone project will give you the capability to publish and share it with others. The recommended and easiest way is to use Java Gradle Plugin. This will automatically add the gradleApi() dependency, generate the required plugin descriptors in the resulting JAR file and configure the Plugin Marker Artifact to be used when publishing.

To start, extract and open the starter project with the name ProjectTrackerPlugin in IntelliJ.

In build.gradle, add java-gradle-plugin and maven inside the plugins task so that it looks like this:

plugins {
  id 'java'
  id 'java-gradle-plugin'
  id 'maven'
  id 'org.jetbrains.kotlin.jvm' version '1.4.32'
}

These two plugins will help you create the plugin and publish it. After you’ve made the changes, load them by pressing Command-Shift-I if you’re on macOS or Control-Shift-O if you’re on PC. Next, create a package inside the src/main/kotlin directory and name it com.raywenderlich.plugin. Inside the directory, first create an open Kotlin class, SaveDependencyTask, and extend it with DefaultTask().

Create a companion object and add the following constants, which will help determine where to save the text file:

companion object {
  private const val targetPath = "newOutput"
  private const val dependencyFileName = "Dependencies.txt"
}

Create a variable configuration which will be of type Collection<String>, and annotate it with @Input:

@Input
var configuration: Collection<String> = mutableListOf()

Doing the above allows you to specify that there’s some input value for this task. It’ll be used to pass the list of configurations from the module using this plugin so that it can have access to all its dependencies.

Next, create a function, checkDependency, and annotate it with @Input. Inside the function, add the following lines:

var file = File("$targetPath/$dependencyFileName")
if (!file.exists()) {
  println("Path does not exist. Creating folder...")

  val folder = File(targetPath)
  if (!folder.exists()) {
    folder.mkdir()
  }
  
  file = File(targetPath, dependencyFileName)
  file.createNewFile()
}
writeDependencies(file)

The code above is pretty much the same code you wrote in your buildSrc plugin. You’re checking if the path exists or not, and if not, you’re creating the required directories and files.

Finally, add the remaining functions, which will actually perform the writing in the text file:

private fun writeDependencies(file: File) {
  file.appendText(getDate(), Charsets.UTF_8)
  file.appendText("\n====Dependencies====\n", Charsets.UTF_8)
  configuration.forEach {
    project.configurations.getByName(it).dependencies.forEach { dependency ->
      file.appendText(
          "${dependency.group}:${dependency.name}:${dependency.version}\n",
          Charsets.UTF_8
      )
    }
  }
  file.appendText("====================", Charsets.UTF_8)
  file.appendText("\n\n\n", Charsets.UTF_8)
}

private fun getDate(): String {
  val current = LocalDateTime.now()
  val formatter = DateTimeFormatter.ofPattern("dd MMM, yyy")
  return current.format(formatter)
}

With this, your task class is ready. Now, create a Kotlin class in the same directory and name it SaveDependency and implement Plugin. In the apply function, register the task you created so that the class looks like this:

package com.raywenderlich.plugin

import org.gradle.api.Plugin
import org.gradle.api.Project

class SaveDependency: Plugin<Project> {
  override fun apply(project: Project) {
    project.tasks.register("writeModuleDependencies", SaveDependencyTask::class.java)
  }
}

Voila! You created your own Gradle plugin in a standalone project.

Next, you’ll look at how you can use this plugin in your Android Studio projects.

Publishing the Plugin in a Local Directory

Your plugin is now ready! To publish it to your local directory, open the build.gradle file in your IntelliJ project and add the following:

uploadArchives {
  repositories.mavenDeployer {
    repository(url: uri('pluginOutput'))
  }
}

After adding the code, sync the Gradle changes again. This task will publish your plugin to your desired location. You can execute the task by either clicking on the green play button next to the task or executing the command ./gradlew -q uploadArchives in the terminal. Build and run and you’ll see the plugin directory in your desired location.

Next, you’ll use it in your Android Studio project. Fire up the ProjectTracker project again, and open the project-level build.gradle file. Inside repositories within the buildscript block, add:

maven {
  url uri('path/to/standalone/plugin/project')
}

And then in dependencies task add:

classpath group: 'com.raywenderlich', name: 'ProjectTrackerPlugin', version: '1.0.0'

Now, your project has access to the plugin files. To apply the plugin, open the module-level build.gradle file and add the following lines at the bottom of your Gradle file:

import com.raywenderlich.plugin.SaveDependency
apply plugin: SaveDependency

Finally, you can call the task in your module-level build.gradle file by adding the following at the bottom of the same file:

writeModuleDependencies {
  configuration = ["implementation"]
}

Now run ./gradlew clean to clean Gradle, and then run ./gradlew -q writeModuleDependencies and enjoy your plugin!

Party Android

Where to Go From Here?

You can download the final project using the Download Materials button at the top or bottom of this tutorial.

Phew! That was a lot of stuff you covered in this tutorial. Now, you can create your own Gradle plugin and use it in different projects.

And there’s still more to explore! To learn more about the Gradle plugin and what else you can do with it, check out Gradle’s official documentation.

If you’re new to the concept of Gradle and don’t know where to start, check out our Gradle Tutorial for Android: Getting Started.

If this tutorial helped you and you ended up creating an awesome plugin that you want to share with everyone, you can publish it on the Gradle plugin portal by following the official documentation on Publishing Plugins to the Gradle Plugin Portal.

We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!

raywenderlich.com Weekly

The raywenderlich.com newsletter is the easiest way to stay up-to-date on everything you need to know as a mobile developer.

Get a weekly digest of our tutorials and courses, and receive a free in-depth email course as a bonus!

Average Rating

5/5

Add a rating for this content

Sign in to add a rating
2 ratings

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK