Home Android & Kotlin Tutorials

Kotlin Coroutines Tutorial for Android : Advanced

Gain a deeper understanding of Kotlin Coroutines in this Advanced tutorial for Android, by replacing common asynchronous programming methods, such as Thread, in an Android app.

1.5/5 2 Ratings

Version

  • Kotlin 1.6, Android 12.0, Android Studio 2021.2.1
Update note: Rod Biresch updated this tutorial for Android 12 (API levels 31, 32), Kotlin 1.6, Coroutines 1.6 and Android Studio 2021.2.1. Rod Biresch also wrote the original tutorial.

In this Advanced Kotlin Coroutines Tutorial for Android, you’ll gain a deeper understanding of Kotlin Coroutines by replacing common asynchronous programming methods in an Android app, such as creating new Threads and using callbacks.

You’ll work on a modified version of the starter project RWDC2018 from the Android Background Processing video course developed by Joe Howard. For more in depth coverage of Kotlin Coroutines see Kotlin Coroutines by Tutorials by Filip Babić and Nishant Srivastava.

Note: This tutorial assumes you have experience with Android and Kotlin. If that’s not the case, check out the Beginning Android Development with Kotlin series and other Kotlin and Android tutorials at our site.

What Are Coroutines?

By now, you’ve read a few articles and blog posts on Kotlin Coroutines. You’re thinking, “Not another definition of coroutines!” Well, even though this isn’t a Getting Started post, it’s still best to understand the history of a topic before deciding on a definition.

Besides, you might learn something new. :]

Note: If you’re already familiar with coroutines in general and have read the official documentation, you might find this explanation redundant. If that’s the case, skip this section and jump straight to the next section.

The Origins

Coroutines are not a new concept. In fact, Melvin Conway, a mathematician, physicist and computer scientist coined the term coroutines in his paper, “Design of a Separable Transition-Diagram Compiler” in 1958. His paper proposed to “organize a compiler as a set of coroutines, which gives the possibility of using separate passes in debugging and then running a single pass compiler in production.”

Coroutines were first implemented as methods in assembly language. They were then implemented in high-level languages like C, C++, C#, Clojure, Java, JavaScript, Python, Ruby, Perl, Scala and, of course, Kotlin.

So, what are coroutines?

Nowadays

The Kotlin Evolution and Enhancement Process, or KEEP, GitHub repository provides a more complete definition. It states that a coroutine is an “instance of suspendable computation.” This is conceptually similar to a thread because it uses a code block to run and has a similar lifecycle.

The KEEP further states that a coroutine is; “Created and started, but it is not bound to any particular thread. It may suspend its execution in one thread and resume in another one. Moreover, like a future or a promise, it may complete with some result, which is either a value or an exception.”

In other words, coroutines mitigate the complications of working with asynchronous programming. The code you write is sequential, making it easier to understand than callbacks and various observable constructs.

Threads are expensive to create and require resources to maintain. That means you can create only so many threads in a system. Opposite of that, coroutines manage their own thread pools. Some dispatchers even share pools. A suspended coroutine doesn’t block any thread and waits for the following available thread to resume.

By decoupling work and threads, it’s possible to create and execute thousands of coroutines. This is within a finite thread pool and without any overhead.

In short, a coroutine is a code component with a lifecycle that is not bound to a single thread. Any thread in the pool can execute, suspend and resume the coroutine.

Getting Started

This tutorial is a bit unconventional when it comes to the code you’ll be working on. First, you’ll experiment with a few concepts and key components of coroutines in Kotlin Playground. Then you’ll switch to an Android app project where you’ll add a lot of advanced coroutine usage.

Key Components

These are the most commonly used Kotlin Coroutine components when implementing coroutines in an Andriod app.

Suspendable Functions

Coroutines work on the principle of suspendable functions. As you already learned, coroutines can pause and resume at any time between any number of threads. This process is code suspension.

It allows coroutines to be lightweight and fast because they don’t really allocate any overhead, such as threads. Instead, they use predefined resources and smart resource management.

The system uses continuations to know when and where to resume a function.

Continuations

When a function suspends, there is information, or state, of the suspended coroutine. Every time a coroutine suspends, it stores its state in a continuation. When the coroutine resumes, the continuation contains enough information to seamlessly continue the rest of the coroutine’s execution.

The Continuation interface consists of a CoroutineContext and a completion callback used to report the success or failure of the coroutine. In the snippet below, an existing asynchronous API service that uses callbacks is wrapped into a suspendable function that propagates the result or error using a Continuation. It’s just an example function, but the idea is there.

suspend fun <Data, Result> suspendAsyncApi(data: Data): Result =
  suspendCancellableCoroutine { continuation ->
    apiService.doAsyncStuff<Data, Result>(data,
        { result -> continuation.resume(result) }, // resume with a result
        { error -> continuation.resumeWithException(error) } // resume with an error
    )
  }

You can see how, by abstracting the function return value with a coroutine and Continuation, you can return a value without actually returning it immediately. You wrap the asynchronous API, which works with callbacks, into a suspendable function that, when called, will seem like sequential code. If you called this function from within another coroutine, it would look similar to this:

val username = suspendAsyncApi<String, String>("userId") // get the username for a given user id

This is not a real API, but you could essentially write your own API, which works similarly to this. The important part is how coroutines and continuations bridge asynchronous and synchronous worlds while keeping the syntax clear.

Coroutine Context

Coroutine context is a persistent set of data about the coroutine. It’s contained within the Continuation, making it an immutable collection of thread-local variables and program states associated with the coroutine.

Since coroutines are lightweight, it’s not a limitation that the coroutine context is immutable. If the coroutine context needs to change, you can simply launch a new coroutine with a mutated context.

Coroutine Builders

To start and run new coroutines, you must use a Coroutine Builder. They take some code and wrap it in a coroutine, passing it to the system for execution. This makes them the bread and butter of coroutines.

The main builder for coroutines is launch(). It creates a new coroutine and launches it instantly by default. It builds and launches a coroutine in the context of some CoroutineScope:

GlobalScope.launch { // CoroutineScope
  // coroutine body
}

Once you get ahold of a CoroutineScope, you can use launch() on it, to start a coroutine. You can use coroutine builders in a normal non-suspending function, or other suspendable functions, which starts nested coroutines.

Executing Concurrently

Another coroutine builder is async(). It’s special because you can use it to return a value from a coroutine, doing so allows concurrent execution. You’d use async() from any coroutine, like so:

GlobalScope.launch { // CoroutineScope
  val someValue = async { getValue() } // value computed in a coroutine
}

However, you can’t use the value just yet. async() returns a Deferred which is a non-blocking cancellable future. To obtain the result you have to call await(). Once you start awaiting, you suspend the wrapping coroutine until you get the computed value.

Blocking Builder

You can use another builder for coroutines, which is a bit unconventional. runBlocking() forces coroutines to be blocking calls.

runBlocking is a builder that blocks the thread until the execution completes to avoid JVM shutdown in special situations like main functions or tests. You should avoid using it in regular Kotlin coroutine code.

To explain how to start and execute Kotlin coroutines, it’s best to take a look at some live snippets of code:

import kotlinx.coroutines.*
import java.lang.Thread

@OptIn(DelicateCoroutinesApi::class)
fun main() {
  GlobalScope.launch {  // launch new coroutine in background and continue
    delay(1000L) // non-blocking delay for 1 second (default time unit is ms)
    println("World!") // print after delay
    val sum1 = async { // non blocking sum1
      delay(100L)
      2 + 2
    }
    val sum2 = async { // non blocking sum2
      delay(500L)
      3 + 3
    }
    println("waiting concurrent sums")
    val total = sum1.await() + sum2.await() // execution stops until both sums are calculated
    println("Total is: $total")
  }
  println("Hello,")     // main thread continues while coroutine executes
  Thread.sleep(2000L)   // block main thread for 2 seconds to keep JVM alive
}

Open in Playground ->

The snippet above launches a Kotlin coroutine which uses delay() to suspend the function for one second. Since Kotlin coroutines don’t block any threads, the code proceeds to the second println() statement and prints Hello,.

Next, the code sleeps the main thread, so the program doesn’t finish before the coroutine completes its execution. The coroutine runs its second line and prints World!.

It then concurrently builds and starts two async coroutines. Finally, when both concurrent operations are complete, it prints the total.

This is a simple but effective way to learn about Kotlin coroutines and the idea behind them.

Take a look at the return type of launch(). It returns a Job, which represents the piece of computation that you wrapped in a coroutine. You can nest jobs and create a child-parent hierarchy.

You’ll see how to use this to cancel coroutines in a later snippet.

One of the things you used above is the GlobalScope instance for the coroutine scope. Let’s see what scopes are and how you should approach them.

CoroutineScope

CoroutineScope confines new coroutines by providing a lifecycle-bound component that binds to a coroutine. Every coroutine builder is an extension function defined in the CoroutineScope type. launch() is an example of a coroutine builder.

You already used GlobalScope, typically, you’d use CoroutineScope over GlobalScope in an Android app to control when lifecycle events occur.

Note: You use GlobalScope to launch top-level coroutines that are not bound to any Job. Global scope operates on the application lifetime. It is easy to unknowingly create resources and memory leaks using GlobalScope therefore, it’s considered a “delicate” API and usage must be annotated appropriately.

In an Android app, you implement CoroutineScope on components with well-defined lifecycles. These components include Activity, Fragment and ViewModel.

Calling launch() on CoroutineScope provides a Job that encapsulates a block of code. Once the scope cancels, all the Kotlin coroutines within clear up their resources and cancel.

Take the following snippet of code:

import kotlinx.coroutines.*

fun main() = runBlocking { // this: CoroutineScope
  launch { 
    delay(200L)
    println("Task from runBlocking")
  }

  coroutineScope { // Creates a new coroutine scope
    val job = launch {
      println("Task from nested launch, this is printed")
      delay(500L) 
      println("Task from nested launch, this won't be printed")
    }

    delay(100L)
    println("Task from first coroutine scope") // Printed before initial launch
    job.cancel() // This cancels nested launch's execution
  }
    
  println("Coroutine scope is over") // This is not printed until nested launch completes/is cancelled
}

Open in Playground ->

Examining the snippet above, you’ll see a few things.

First, you force the coroutines to be blocking, so you don’t have to sleep the program as you did before. Then, you launch a new coroutine which has an initial delay. After that, you use coroutineScope() to create a new scope. You then launch a coroutine within it, saving the returned Job.

Because you delay the initial launch(), it doesn’t run until the coroutineScope() executes fully. However, within the coroutineScope(), you store and delay the Job and the nested coroutine. Since you cancel it after it delays, it’ll only print the first statement, ultimately canceling before the second print statement. And, as the coroutineScope() finishes, the initial launch() finishes its delay, and it can proceed with execution.

Finally, once the scope finishes, the runBlocking() can finish as well. This ends the program. It’s important to understand this flow of execution to build stable coroutines without race conditions or hanging resources.

Canceling a Job

In the previous section, you saw how to cancel the execution of a coroutine. You should understand that a Job is a cancellable component with a lifecycle.

Jobs are typically created by calling launch(). You can also create them using a constructor – Job(). They can live within the hierarchy of other jobs, either as the parent or a child. If you cancel a parent Job, you also cancel all its children.

If a child Job fails or cancels, then its parent and parent hierarchy will also cancel. The exception the hierarchy receives is, of course, a CancellationException.

Note: There is a special type of a Job which doesn’t cancel if one of its children fail – the SupervisorJob. You can check it out at the official documentation.

So, the failure of a child will, by default, cancel its parent and any other children in the hierarchy. Sometimes you need to wait until a coroutine execution is effectively canceled. In that case, you can call job.cancelAndJoin() instead of job.cancel().

import kotlinx.coroutines.*

fun main() = runBlocking {
  val startTime = System.currentTimeMillis()
  val job = launch(Dispatchers.Default) {
    var nextPrintTime = startTime
    var i = 0
    while (isActive) { // cancelable computation loop
      // print a message twice a second
      if (System.currentTimeMillis() >= nextPrintTime) {
        println("I'm sleeping ${i++} ...")
        nextPrintTime += 500L
      }
    }
  }
  delay(1300L) // delay a bit
  println("main: I'm tired of waiting!")
  job.cancelAndJoin() // cancels the job and waits for its completion
  println("main: Now I can quit.")    
}

Open in Playground ->

The output for the program will be a few prints from the while loop, following with the cancel and finally the main() finishing.

There are benefits to canceling a coroutine in an Android app. For example, say an app goes into the background and an Activity stops. In that case, you should cancel any long-running API calls to clean up resources. This will help you avoid possible memory leaks or unwanted behavior.

You can cancel a Job, along with any children, from an Activity event like onStop(). It’s even easier if you do it through the use of CoroutineScope, but you’ll do that later.

CoroutineDispatchers

Dispatchers determine what thread or thread pool the coroutine uses for execution. The dispatcher can confine a coroutine to a specific thread. It can also dispatch it to a thread pool. Less commonly, it can allow a coroutine to run unconfined, without a specific threading rule, which can be unpredictable.

Here are some common dispatchers:

  • Dispatchers.Main: This dispatcher confines coroutines to the main thread for UI-driven programs like Swing, JavaFX, or Android apps. It’s important to note that this dispatcher doesn’t work without adding an environment-specific Main dispatcher dependency in Gradle or Maven.
    Use Dispatchers.Main.immediate for optimum UI performance on updates.
  • Dispatchers.Default: This is the default dispatcher used by standard builders. It’s backed by a shared pool of JVM threads. Use this dispatcher for CPU intensive computations.
  • Dispatchers.IO: Use this dispatcher for I/O-intensive blocking tasks that uses a shared pool of threads.
  • Dispatchers.Unconfined: This dispatcher doesn’t confine coroutines to any specific thread. The coroutine starts the execution in the inherited CoroutineDispatcher that called it, but only until the first suspension point. After the suspension ends, it resumes in the thread that is fully determined by the suspending function that was invoked.

    This lack of confinement may lead to a coroutine destined for background execution to run on the main thread, so use it sparingly.

import kotlinx.coroutines.*

@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking<Unit> {
  launch { //context of the parent, main runBlocking coroutine
    println("main runBlocking: I'm working in thread ${Thread.currentThread().name}")
  }
  launch(Dispatchers.Unconfined) { //not confined -- will inmediatly run in main thread but not after suspension
    println("Unconfined: I'm working in thread ${Thread.currentThread().name}")
    delay(100L) // delays (suspends) execution 100 ms
    println("Unconfined: I'm working in thread ${Thread.currentThread().name}")
  }
  launch(Dispatchers.Default) { //will get dispatched to DefaultDispatcher 
    println("Default: I'm working in thread ${Thread.currentThread().name}")
  }
  launch(newSingleThreadContext("MyOwnThread")) {// will get its own new thread
    println("newSingleThreadContext: I'm working in thread ${Thread.currentThread().name}")
  }    
}

Open in Playground ->

The print order changes per execution in the playground.

You’ll see how each of the dispatchers prints its own context, its own thread. Furthermore, you can see how you can create your own single-threaded contexts if you need a specific thread for some coroutine.

Handling Exceptions

On the JVM, threads are at the core of the Kotlin coroutines machinery. The JVM has a well-defined way of dealing with terminating threads and uncaught exceptions.

If an uncaught exception occurs in a thread, the JVM will query the thread for an UncaughtExceptionHandler. The JVM then passes it the terminating thread and the uncaught exception. This is important because coroutines and Java concurrency deal with the same exception behavior.

Coroutine builders fall into two exception categories. The first propagates automatically, like the launch(), so if bad things happen, you’ll know soon enough. The second exposes exceptions for the user to handle, such as async(). They won’t propagate until you call await() to get the value.

In Android, builders that propagate exceptions also rely on Thread.UncaughtExceptionHandler. It installs as a global coroutine exception handler. However, coroutine builders allow the user to provide a CoroutineExceptionHandler to have more control over how you deal with exceptions.

import kotlinx.coroutines.*

@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking<Unit> {
  // propagating exception to the default Thread.UncaughtExceptionHandler
  val job = GlobalScope.launch {
    throw AssertionError()
  }

  // blocks thread execution until coroutine completes
  job.join()

  // launches async coroutine but exception is not propagated until await is called
  val deferred = GlobalScope.async(Dispatchers.Default) {
    throw AssertionError()
  }

  //defines a specific handler
  val handler = CoroutineExceptionHandler { _, exception -> 
    println("We caught $exception") 
  }

  // propagating exception using a custom CoroutineExceptionHandler
  GlobalScope.launch(handler) {
    throw AssertionError()
  } 

  // This exception is finally propagated calling await and should be handled by user, eg. with try {} catch {}
  deferred.await() 
}

Open in Playground ->

You should see an error being caught immediately. After that, comment out the first throw clause. You should once again see an exception thrown, but this time from async(). If you comment out the await()CoroutineExceptionHandler catches the exception, and prints out which exception happened.

Knowing this, there are three ways to handle exceptions. The first is using try/catch within a launch(), when you don’t have a custom exception handler. The second is by wrapping await() calls in a try/catch block. The last one is to use an exception handler, to provide one place to catch exceptions.

Coding Time

You’re going to work on a modified version of the RWDC2018 app. The modified app only displays photos taken at RWDevCon 2018.

The app retrieves these photos by using background threads. You’re going to replace the background threads implementation with Kotlin Coroutines.

Downloading the Project

Download the starter and final projects by clicking the Download Materials button at the top or bottom of this tutorial. Then, import the starter project into Android Studio.

Build and run the app, and you’ll see the following:


Main screen

Main screen after scrolling

Take a moment to familiarize yourself with the structure of the project.

Main Activity

Next, navigate to the PhotosRepository.kt in Android Studio. This class contains the thread code to download the banner and photos for the RecyclerView. The photos download in a background thread. You then store the results in LiveData using postValue(). postValue() updates the data on the main thread.


Photos Repository

Next, you’ll start modifying the project to use coroutines.

Adding Dependencies

First, you need to add the Kotlin Coroutine dependencies to the app module. Open build.gradle in the app module and add the following dependencies:

implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.2'
  • kotlinx-coroutines-core: Provides the core primitives to work with coroutines, such as builders, dispatchers and suspend functions.
  • kotlinx-coroutines-android: Provides Dispatchers.Main context for Android applications.

Sync the project to download the dependencies.

Life-cycle Awareness

Now that you have the dependency for Kotlin coroutines in your project, you can start implementing them. You’ll begin with the end in mind. It sounds kind of apocalyptic, but it’s not! :]

You must prepare your code to clean up active coroutines before implementing them. You’ll provide a way to cancel any active coroutines if the user decides to rotate or background the app, triggering Fragment and Activity life-cycle.

You’ll extend these Android lifecycle events to Kotlin classes which will handle coroutines internally.

Updating the Repository

First, open Repository.kt and modify it as follows:

interface Repository : DefaultLifecycleObserver {
  fun getPhotos(): LiveData<List<String>?>
  fun getBanner(): LiveData<String?>

  fun registerLifecycle(lifecycle: Lifecycle)
}

Here, you extended from DefaultLifecycleObserver. This allows you to listen to lifecycle events and provide your custom logic.

The new registerLifecycle function will provide the ability to hook in the life-cycle from the PhotosFragment.

Updating Injection Singleton

Next, open Injection.kt. Change the method signature on provideViewModelFactory() to the following:

fun provideViewModelFactory(lifecycle: Lifecycle): PhotosViewModelFactory {
  val repository = provideRepository()
  repository.registerLifecycle(lifecycle)
  return PhotosViewModelFactory(repository)
}

You included a Lifecycle parameter. Then, register the Lifecycle in the Repository.

Updating the PhotosFragment

Now, open PhotosFragment.kt and change the following method:

override fun onAttach(context: Context) {
  super.onAttach(context)

  val viewModelFactory = Injection.provideViewModelFactory(lifecycle)
  viewModel = ViewModelProvider(this, viewModelFactory).get(PhotosViewModel::class.java)
}

You’ve provided the Fragment’s lifecycle as an argument in provideViewModelFactory().

Registering the Lifecycle

Next, open PhotosRepository.kt again, and implement the new registerLifecycle() function.

override fun registerLifecycle(lifecycle: Lifecycle) {
  lifecycle.addObserver(this)
}

This adds an observer that will be notified when the Fragment changes state.

Main-Safe Design

Google encourages main-safety when writing coroutines. The concept is similar to how the Android system creates a main thread when an app launches.

The main thread is in charge of dispatching events to the appropriate user interface widgets. You should delegate I/O and CPU-intensive operations to a background thread to avoid jank in the app.

Main-safety is a design pattern for Kotlin coroutines. It lets coroutines use the Dispatchers.Main, or main thread, as a default threading context. They then favor delegating to Dispatchers.IO for heavy I/O operations or Dispatchers.Default for CPU heavy operations.

A CoroutineScope interface is available to classes which require scoped coroutines. However, you must define a CoroutineContext instance which the scope will use for all the coroutines. Let’s do that.

Defining CoroutineScope

First, open the PhotosRepository and modify it as follows:

class PhotosRepository : Repository, CoroutineScope {
  private val TAG = PhotosRepository::class.java.simpleName
  private val job: Job = Job()
  override val coroutineContext: CoroutineContext
    get() = Dispatchers.Main + job

  //...omitted code...

}

You’ve implemented CoroutineScope, and defined a Job and CoroutineContext.

The Job will determine if the coroutine is active and you will then use to cancel it. Per the main-safe design pattern, the Dispatchers.Main defines the CoroutineScope.

Hooking Into the Life-cycle

Add the following code to the PhotosRepository.

override fun onStop(owner: LifecycleOwner) {
  super.onStop(owner)
  cancelJob()
}

private fun cancelJob() {
  Log.d(TAG, "cancelJob()")
  if (job.isActive) {
    Log.d(TAG, "Job active, canceling")
    job.cancel()
  }
}

Because you previously registered this object as an observer to the Fragment’s life-cycle, Android will call the onStop() method when the Lifecycle.Event.ON_STOP event occurred in the PhotosFragment. Here you’ve canceled the job.

A typical implementation is to include a Job instance plus a Dispatcher as context for the scope. Implementing the interface will let you call launch() at any place and handle cancellation with the Job you provided. The suspend functions can then call withContext(Dispatchers.IO) or withContext(Dispatchers.Default) to delegate work to background threads if necessary, keeping the initial threading tied to the main thread.

Introducing Coroutines

Currently, both the fetchBanner() and fetchPhotos() use a Runnable and execute with a new Thread. Firstly, you have to change the method implementation to use Kotlin coroutines. Then, you’ll run the project, to see if everything works as before.

The banner and images will download in the background. They’ll display like before with the separate background thread implementation.

Modify fetchBanner() and fetchPhotos() methods as follows:

// Dispatchers.Main
private suspend fun fetchBanner() {
  val banner = withContext(Dispatchers.IO) {
    // Dispatchers.IO
    val photosString = PhotosUtils.photoJsonString()
    // Dispatchers.IO
    PhotosUtils.bannerFromJsonString(photosString)
  }
  // Dispatchers.Main
  bannerLiveData.value = banner
}

// Dispatchers.Main
private suspend fun fetchPhotos() {
  val photos = withContext(Dispatchers.IO) {
    // Dispatchers.IO
    val photosString = PhotosUtils.photoJsonString()
    // Dispatchers.IO
    PhotosUtils.photoUrlsFromJsonString(photosString)
  }
  // Dispatchers.Main
  photosLiveData.value = photos
}

The functions above are annotated with comments. They show what thread or thread pool executes each line of code.

In this case, the thread is Dispatchers.Main and the thread pool is Dispatchers.IO. This helps visualize the main-safety design.

Notice that you don’t need to use liveData.postValue() anymore because now you’re setting the Livedata values in the main thread.

Because the modified methods are now marked with suspend, you have to change these function declarations, to avoid compiler errors:

override fun getPhotos(): LiveData<List<String>?> {
  launch { fetchPhotos() }
  return photosLiveData
}

override fun getBanner(): LiveData<String?> {
  launch { fetchBanner() }
  return bannerLiveData
}

Now, build and run the app. Open Logcat, filter with PhotosRepository and then background the app. You should see the following:


PhotosRepository logs

PhotosRepository received an Lifecycle.Event.ON_STOP triggering an active coroutine Job to cancel.

Congratulations! You’ve successfully converted asynchronous code to Kotlin coroutines. And everything still works but looks nicer! :]

Where to Go From Here?

You can download the completed project by clicking on the Download Materials button at the top or bottom of the tutorial.

To continue building your understanding of Kotlin Coroutines and how you can use them in Android app development check resources such as: Kotlin Coroutines by Tutorials book, kotlinx.coroutines and Coroutines Guide.

Please join the forum discussion below if you have any questions or comments.

Contributors

Comments

Reviews

More like this