Android Kotlin Fundamentals 04.2: Complex lifecycle situations

This codelab is part of the Android Kotlin Fundamentals course. You'll get the most value out of this course if you work through the codelabs in sequence. All the course codelabs are listed on the Android Kotlin Fundamentals codelabs landing page.

Introduction

In the last codelab, you learned about the Activity and Fragment lifecycles, and you explored the methods that are called when the lifecycle state changes in activities and fragments. In this codelab, you explore the activity lifecycle in greater detail. You also learn about Android Jetpack's lifecycle library, which can help you manage lifecycle events with code that's better organized and easier to maintain.

What you should already know

  • What an activity is, and how to create one in your app.
  • The basics of the Activity and Fragment lifecycles, and the callbacks that are invoked when an activity moves between states.
  • How to override the onCreate() and onStop() lifecycle callback methods to perform operations at different times in the activity or fragment lifecycle.

What you'll learn

  • How to set up, start, and stop parts of your app in the lifecycle callbacks.
  • How to use the Android lifecycle library to create a lifecycle observer, and make the activity and fragment lifecycle easier to manage.
  • How Android process shutdowns affect the data in your app, and how to save and restore that data automatically when Android closes your app.
  • How device rotation and other configuration changes create changes to lifecycle states and affect the state of your app.

What you'll do

  • Modify the DessertClicker app to include a timer function, and start and stop that timer at various times in the activity lifecycle.
  • Modify the app to use the Android lifecycle library, and convert the DessertTimer class into a lifecycle observer.
  • Set up and use the Android Debug Bridge (adb) to simulate your app's process shutdown and the lifecycle callbacks that occur then.
  • Implement the onSaveInstanceState() method to retain app data that may be lost if the app is unexpectedly closed. Add code to restore that data when the app starts again.

In this codelab, you expand on the DessertClicker app from the previous codelab. You add a background timer, then convert the app to use the Android lifecycle library.

In the previous codelab, you learned how to observe the activity and fragment lifecycles by overriding various lifecycle callbacks, and logging when the system invokes those callbacks. In this task, you explore a more complex example of managing lifecycle tasks in the DessertClicker app. You use a timer that prints a log statement every second, with the count of the number of seconds it has been running.

Step 1: Set up DessertTimer

  1. Open the DessertClicker app from the last codelab. (You can download DessertClickerLogs here if you don't have the app.)
  2. In the Project view, expand java > com.example.android.dessertclicker and open DessertTimer.kt. Notice that right now all the code is commented out, so it doesn't run as part of the app.
  3. Select all the code in the editor window. Select Code > Comment with Line Comment, or press Control+/ (Command+/ on a Mac). This command uncomments all the code in the file. (Android Studio might show unresolved reference errors until you rebuild the app.)
  4. Notice that the DessertTimer class includes startTimer() and stopTimer(), which start and stop the timer. When startTimer() is running, the timer prints a log message every second, with the total count of the seconds the time has been running. The stopTimer() method, in turn, stops the timer and the log statements.
  1. Open MainActivity.kt. At the top of the class, just below the dessertsSold variable, add a variable for the timer:
private lateinit var dessertTimer : DessertTimer;
  1. Scroll down to onCreate() and create a new DessertTimer object, just after the call to setOnClickListener():
dessertTimer = DessertTimer()


Now that you have a dessert timer object, consider where you should start and stop the timer to get it to run only when the activity is on-screen. You look at a few options in the next steps.

Step 2: Start and stop the timer

The onStart() method is called just before the activity becomes visible. The onStop() method is called after the activity stops being visible. These callbacks seem like good candidates for when to start and stop the timer.

  1. In the MainActivity class, start the timer in the onStart() callback:
override fun onStart() {
   super.onStart()
   dessertTimer.startTimer()

   Timber.i("onStart called")
}
  1. Stop the timer in onStop():
override fun onStop() {
   super.onStop()
   dessertTimer.stopTimer()

   Timber.i("onStop Called")
}
  1. Compile and run the app. In Android Studio, click the Logcat pane. In the Logcat search box, enter dessertclicker, which will filter by both the MainActivity and DessertTimer classes. Notice that once the app starts, the timer also starts running immediately.
  2. Click the Back button and notice that the timer stops again. The timer stops because both the activity and the timer it controls have been destroyed.
  3. Use the recents screen to return to the app. Notice in Logcat that the timer restarts from 0.
  4. Click the Share button. Notice in Logcat that the timer is still running.

  5. Click the Home button. Notice in Logcat that the timer stops running.
  6. Use the recents screen to return to the app. Notice in Logcat that the timer starts up again from where it left off.
  7. In MainActivity, in the onStop() method, comment out the call to stopTimer(). Commenting out stopTimer() demonstrates the case where you start an operation in onStart(), but forget to stop it again in onStop().
  8. Compile and run the app, and click the Home button after the timer starts. Even though the app is in the background, the timer is running, and continually using system resources. Having the timer continue to run is a memory leak for your app, and probably not the behavior you want.

    The general pattern is that when you set up or start something in a callback, you stop or remove that thing in the corresponding callback. This way, you avoid having anything running when it's no longer needed.
  1. Uncomment the line in onStop() where you stop the timer.
  2. Cut and paste the startTimer() call from onStart() to onCreate(). This change demonstrates the case where you both initialize and start a resource in onCreate(), rather than using onCreate() to initialize it and onStart() to start it.
  3. Compile and run the app. Notice that the timer starts running, as you would expect.
  4. Click Home to stop the app. The timer stops running, as you would expect.
  5. Use the recents screen to return to the app. Notice that the timer does not start again in this case, because onCreate() is only called when the app starts—it's not called when an app returns to the foreground.

Key points to remember:

  • When you set up a resource in a lifecycle callback, also tear the resource down.
  • Do setup and teardown in corresponding methods.
  • If you set up something in onStart(), stop or tear it down again in onStop().

In the DessertClicker app, it's fairly easy to see that if you started the timer in onStart(), then you need to stop the timer in onStop(). There's only one timer, so stopping the timer is not difficult to remember.

In a more complex Android app, you might set up many things in onStart() or onCreate(), then tear them all down in onStop() or onDestroy(). For example, you might have animations, music, sensors, or timers that you need to both set up and tear down, and start and stop. If you forget one, that leads to bugs and headaches.

The lifecycle library, which is part of Android Jetpack, simplifies this task. The library is especially useful in cases where you have to track many moving parts, some of which are at different lifecycle states. The library flips around the way lifecycles work: Usually the activity or fragment tells a component (such as DessertTimer) what to do when a lifecycle callback occurs. But when you use the lifecycle library, the component itself watches for lifecycle changes, then does what's needed when those changes happen.

There are three main parts of the lifecycle library:

  • Lifecycle owners, which are the components that have (and thus "own") a lifecycle. Activity and Fragment are lifecycle owners. Lifecycle owners implement the LifecycleOwner interface.
  • The Lifecycle class, which holds the actual state of a lifecycle owner and triggers events when lifecycle changes happen.
  • Lifecycle observers, which observe the lifecycle state and perform tasks when the lifecycle changes. Lifecycle observers implement the LifecycleObserver interface.

In this task, you convert the DessertClicker app to use the Android lifecycle library, and learn how the library makes working with the Android activity and fragment lifecycles easier to manage.

Step 1: Turn DessertTimer into a LifecycleObserver

A key part of the lifecycle library is the concept of lifecycle observation. Observation enables classes (such as DessertTimer) to know about the activity or fragment lifecycle, and start and stop themselves in response to changes to those lifecycle states. With a lifecycle observer, you can remove the responsibility of starting and stopping objects from the activity and fragment methods.

  1. Open the DesertTimer.kt class.
  2. Change the class signature of the DessertTimer class to look like this:
class DessertTimer(lifecycle: Lifecycle) : LifecycleObserver {

This new class definition does two things:

  • The constructor takes a Lifecycle object, which is the lifecycle that the timer is observing.
  • The class definition implements the LifecycleObserver interface.
  1. Below the runnable variable, add an init block to the class definition. In the init block, use the addObserver() method to connect the lifecycle object passed in from the owner (the activity) to this class (the observer).
 init {
   lifecycle.addObserver(this)
}
  1. Annotate startTimer() with the @OnLifecycleEvent annotation, and use the ON_START lifecycle event. All the lifecycle events that your lifecycle observer can observe are in the Lifecycle.Event class.
@OnLifecycleEvent(Lifecycle.Event.ON_START)
fun startTimer() {
  1. Do the same thing to stopTimer(), using the ON_STOP event:
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
fun stopTimer()

Step 2: Modify MainActivity

Your MainActivity class is already a lifecycle owner through inheritance, because the FragmentActivity superclass implements LifecycleOwner. Therefore, there's nothing you need to do to make your activity lifecycle-aware. All you have to do is pass the activity's lifecycle object into the DessertTimer constructor.

  1. Open MainActivity. In the onCreate() method, modify the initialization of DessertTimer to include this.lifecycle:
dessertTimer = DessertTimer(this.lifecycle)

The lifecycle property of the activity holds the Lifecycle object this activity owns.

  1. Remove the call to startTimer() in onCreate(), and the call to stopTimer() in onStop(). You don't need to tell DessertTimer what to do in the activity anymore, because DessertTimer is now observing the lifecycle itself and is automatically notified when the lifecycle state changes. All you do in these callbacks now is log a message.
  2. Compile and run the app, and open the Logcat. Notice that the timer has started running, as expected.
  3. Click the home button to put the app into the background. Notice that the timer has stopped running, as expected.

What happens to your app and its data if Android shuts down that app while it is in the background? This tricky edge case is important to understand.

When your app goes into the background, it's not destroyed, it's only stopped and waiting for the user to return to it. But one of the Android OS's main concerns is keeping the activity that's in the foreground running smoothly. For example, if your user is using a GPS app to help them catch a bus, it's important to render that GPS app quickly and keep showing the directions. It's less important to keep the DessertClicker app, which the user might not have looked at for a few days, running smoothly in the background.

Android regulates background apps so that the foreground app can run without problems. For example, Android limits the amount of processing that apps running in the background can do.

Sometimes Android even shuts down an entire app process, which includes every activity associated with the app. Android does this kind of shutdown when the system is stressed and in danger of visually lagging, so no additional callbacks or code is run at this point. Your app's process is simply shut down, silently, in the background. But to the user, it doesn't look like the app has been closed. When the user navigates back to an app that the Android OS has shut down, Android restarts that app.

In this task, you simulate an Android process shutdown and examine what happens to your app when it starts up again.

Step 1: Use adb to simulate a process shutdown

The Android Debug Bridge (adb) is a command-line tool that lets you send instructions to emulators and devices attached to your computer. In this step, you use adb to close your app's process and see what happens when Android shuts down your app.

  1. Compile and run your app. Click the cupcake a few times.
  2. Press the Home button to put your app into the background. Your app is now stopped, and the app is subject to being closed if Android needs the resources that the app is using.
  3. In Android Studio, click the Terminal tab to open the command-line terminal.
  4. Type adb and press Return.

    If you see a lot of output that begins with Android Debug Bridge version X.XX.X and ends with tags to be used by logcat (see logcat —help), everything is fine. If instead you see adb: command not found, make sure the adb command is available in your execution path. For instructions, see "Add adb to your execution path" in the Utilities chapter.
  5. Copy and paste this comment into the command line and press Return:
adb shell am kill com.example.android.dessertclicker

This command tells any connected devices or emulators to stop the process with the dessertclicker package name, but only if the app is in the background. Because your app was in the background, nothing shows on the device or emulator screen to indicate that your process has been stopped. In Android Studio, click the Run tab to see a message that says "Application terminated." Click the Logcat tab to see that the onDestroy() callback was never run—your activity simply ended.

  1. Use the recents screen to return to the app. Your app appears in recents whether it has been put into the background or has been stopped altogether. When you use the recents screen to return to the app, the activity is started up again. The activity goes through the entire set of startup lifecycle callbacks, including onCreate().
  2. Notice that when the app restarted, it resets your "score" (both the number of desserts sold and the total dollars) to the default values (0). If Android shut down your app, why didn't it save your state?

    When the OS restarts your app for you, Android tries its best to reset your app to the state it had before. Android takes the state of some of your views and saves it in a bundle whenever you navigate away from the activity. Some examples of data that's automatically saved are the text in an EditText (as long as they have an ID set in the layout), and the back stack of your activity.

    However, sometimes the Android OS doesn't know about all your data. For example, if you have a custom variable like revenue in the DessertClicker app, the Android OS doesn't know about this data or its importance to your activity. You need to add this data to the bundle yourself.

Step 2: Use onSaveInstanceState() to save bundle data

The onSaveInstanceState() method is the callback you use to save any data that you might need if the Android OS destroys your app. In the lifecycle callback diagram, onSaveInstanceState() is called after the activity has been stopped. It's called every time your app goes into the background.

Think of the onSaveInstanceState() call as a safety measure; it gives you a chance to save a small amount of information to a bundle as your activity exits the foreground. The system saves this data now because if it waited until it was shutting down your app, the OS might be under resource pressure. Saving the data each time ensures that update data in the bundle is available to restore, if it is needed.

  1. In MainActivity, override the onSaveInstanceState() callback, and add a Timber log statement.
override fun onSaveInstanceState(outState: Bundle) {
   super.onSaveInstanceState(outState)

   Timber.i("onSaveInstanceState Called")
}
  1. Compile and run the app, and click the Home button to put it into the background. Notice that the onSaveInstanceState() callback occurs just after onPause() and onStop():
  2. At the top of the file, just before the class definition, add these constants:
const val KEY_REVENUE = "revenue_key"
const val KEY_DESSERT_SOLD = "dessert_sold_key"
const val KEY_TIMER_SECONDS = "timer_seconds_key"

You will use these keys for both saving and retrieving data from the instance state bundle.

  1. Scroll down to onSaveInstanceState(), and notice the outState parameter, which is of type Bundle.

    A bundle is a collection of key-value pairs, where the keys are always strings. You can put primitive values, such as int and boolean values, into the bundle.
    Because the system keeps this bundle in RAM, it's a best practice to keep the data in the bundle small. The size of this bundle is also limited, though the size varies from device to device. Generally you should store far less than 100k, otherwise you risk crashing your app with the TransactionTooLargeException error.
  2. In onSaveInstanceState(), put the revenue value (an integer) into the bundle with the putInt() method:
outState.putInt(KEY_REVENUE, revenue)

The putInt() method (and similar methods from the Bundle class like putFloat() and putString() takes two arguments: a string for the key (the KEY_REVENUE constant), and the actual value to save.

  1. Repeat the same process with the number of desserts sold, and the status of the timer:
outState.putInt(KEY_DESSERT_SOLD, dessertsSold)
outState.putInt(KEY_TIMER_SECONDS, dessertTimer.secondsCount)

Step 3: Use onCreate() to restore bundle data

  1. Scroll up to onCreate(), and examine the method signature:
override fun onCreate(savedInstanceState: Bundle) {

Notice that onCreate() gets a Bundle each time it is called. When your activity is restarted due to a process shut-down, the bundle that you saved is passed to onCreate(). If your activity was starting fresh, this bundle in onCreate() is null. So if the bundle is not null, you know you're "re-creating" the activity from a previously known point.

  1. Add this code to onCreate(), after the DessertTimer setup:
if (savedInstanceState != null) {
   revenue = savedInstanceState.getInt(KEY_REVENUE, 0)
}

The test for null determines whether there is data in the bundle, or if the bundle is null, which in turn tells you if the app has been started fresh or has been re-created after a shutdown. This test is a common pattern for restoring data from the bundle.

Notice that the key you used here (KEY_REVENUE) is the same key you used for putInt(). To make sure you use the same key each time, it is a best practice to define those keys as constants. You use getInt() to get data out of the bundle, just as you used putInt() to put data into the bundle. The getInt() method takes two arguments:

  • A string that acts as the key, for example "key_revenue" for the revenue value.
  • A default value in case no value exists for that key in the bundle.

The integer you get from the bundle is then assigned to the revenue variable, and the UI will use that value.

  1. Add getInt() methods to restore the number of desserts sold and the value of the timer:
if (savedInstanceState != null) {
   revenue = savedInstanceState.getInt(KEY_REVENUE, 0)dessertsSold = savedInstanceState.getInt(KEY_DESSERT_SOLD, 0)
   dessertTimer.secondsCount =
       savedInstanceState.getInt(KEY_TIMER_SECONDS, 0)
}
  1. Compile and run the app. Press the cupcake at least five times until it switches to a donut. Click Home to put the app into the background.
  2. In the Android Studio Terminal tab, run adb to shut down the app's process.
adb shell am kill com.example.android.dessertclicker
  1. Use the recents screen to return to the app. Notice that this time the app returns with the correct revenue and desserts sold values from the bundle. But also notice that the dessert has returned to a cupcake. There's one more thing left to do to ensure that the app returns from a shutdown exactly the way it was left.
  2. In MainActivity, examine the showCurrentDessert() method. Notice that this method determines which dessert image should be displayed in the activity based on the current number of desserts sold and the list of desserts in the allDesserts variable.
for (dessert in allDesserts) {
   if (dessertsSold >= dessert.startProductionAmount) {
       newDessert = dessert
   }
    else break
}

This method relies on the number of desserts sold to choose the right image. Therefore, you don't need to do anything to store a reference to the image in the bundle in onSaveInstanceState(). In that bundle, you're already storing the number of desserts sold.

  1. In onCreate(), in the block that restores the state from the bundle, call showCurrentDessert():
 if (savedInstanceState != null) {
   revenue = savedInstanceState.getInt(KEY_REVENUE, 0)
   dessertsSold = savedInstanceState.getInt(KEY_DESSERT_SOLD, 0)
   dessertTimer.secondsCount = 
      savedInstanceState.getInt(KEY_TIMER_SECONDS, 0)
   showCurrentDessert()                   
}
  1. Compile and run the app, and put it in the background. Use adb to shut down the process. Use the recents screen to return to the app. Note now that both the values for desserts told, total revenue, and the dessert image are correctly restored.

There's one last special case in managing the activity and fragment lifecycle that is important to understand: how configuration changes affect the lifecycle of your activities and fragments.

A configuration change happens when the state of the device changes so radically that the easiest way for the system to resolve the change is to completely shut down and rebuild the activity. For example, if the user changes the device language, the whole layout might need to change to accommodate different text directions. If the user plugs the device into a dock or adds a physical keyboard, the app layout may need to take advantage of a different display size or layout. And if the device orientation changes—if the device is rotated from portrait to landscape or back the other way—the layout may need to change to fit the new orientation.

Step 1: Explore device rotation and the lifecycle callbacks

  1. Compile and run your app, and open Logcat.
  2. Rotate the device or emulator to landscape mode. You can rotate the emulator left or right with the rotation buttons, or with the Control and arrow keys (Command and arrow keys on a Mac).
  3. Examine the output in Logcat. Filter the output on MainActivity.
    Notice that when the device or emulator rotates the screen, the system calls all the lifecycle callbacks to shut down the activity. Then, as the activity is re-created, the system calls all the lifecycle callbacks to start the activity.
  4. In MainActivity, comment out the entire onSaveInstanceState() method.
  5. Compile and run your app again. Click the cupcake a few times, and rotate the device or emulator. This time, when the device is rotated and the activity is shut down and re-created, the activity starts up with default values.

    When a configuration change occurs, Android uses the same instance state bundle that you learned about in the previous task to save and restore the state of the app. As with a process shutdown, use onSaveInstanceState() to put your app's data into the bundle. Then restore the data in onCreate(), to avoid losing activity state data if the device is rotated.
  6. In MainActivity, uncomment the onSaveInstanceState() method, run the app, click the cupcake, and rotate the app or device. Notice this time the dessert data is retained across activity rotation.

Android Studio project: DessertClickerFinal

Lifecycle tips

  • If you set up or start something in a lifecycle callback, stop or remove that thing in the corresponding callback. By stopping the thing, you make sure it doesn't keep running when it's no longer needed. For example, if you set up a timer in onStart(), you need to pause or stop the timer in onStop().
  • Use onCreate() only to initialize the parts of your app that run once, when the app first starts. Use onStart() to start the parts of your app that run both when the app starts, and each time the app returns to the foreground.

Lifecycle library

  • Use the Android lifecycle library to shift lifecycle control from the activity or fragment to the actual component that needs to be lifecycle-aware.
  • Lifecycle owners are components that have (and thus "own") lifecycles, including Activity and Fragment. Lifecycle owners implement the LifecycleOwner interface.
  • Lifecycle observers pay attention to the current lifecycle state and perform tasks when the lifecycle changes. Lifecycle observers implement the LifecycleObserver interface.
  • Lifecycle objects contain the actual lifecycle states, and they trigger events when the lifecycle changes.

To create a lifecycle-aware class:

  • Implement the LifecycleObserver interface in classes that need to be lifecycle-aware.
  • Initialize a lifecycle observer class with the lifecycle object from the activity or fragment.
  • In the lifecycle observer class, annotate lifecycle-aware methods with the lifecycle state change they are interested in.

    For example, the @OnLifecycleEvent(Lifecycle.Event.ON_START)annotation indicates that the method is watching the onStart lifecycle event.

Process shutdowns and saving activity state

  • Android regulates apps running in the background so that the foreground app can run without problems. This regulation includes limiting the amount of processing that apps in the background can do, and sometimes even shutting down your entire app process.
  • The user cannot tell if the system has shut down an app in the background. The app still appears in the recents screen and should restart in the same state in which the user left it.
  • The Android Debug Bridge (adb) is a command-line tool that lets you send instructions to emulators and devices attached to your computer. You can use adb to simulate a process shutdown in your app.
  • When Android shuts down your app process, the onDestroy() lifecycle method is not called. The app just stops.

Preserving activity and fragment state

  • When your app goes into the background, just after onStop() is called, app data is saved to a bundle. Some app data, such as the contents of an EditText, is automatically saved for you.
  • The bundle is an instance of Bundle, which is a collection of keys and values. The keys are always strings.
  • Use the onSaveInstanceState() callback to save other data to the bundle that you want to retain, even if the app was automatically shut down. To put data into the bundle, use the bundle methods that start with put, such as putInt().
  • You can get data back out of the bundle in the onRestoreInstanceState() method, or more commonly in onCreate(). The onCreate() method has a savedInstanceState parameter that holds the bundle.
  • If the savedInstanceState variable contains null, the activity was started without a state bundle and there is no state data to retrieve.
  • To retrieve data from the bundle with a key, use the Bundle methods that start with get, such as getInt().

Configuration changes

  • A configuration change happens when the state of the device changes so radically that the easiest way for the system to resolve the change is to shut down and rebuild the activity.
  • The most common example of a configuration change is when the user rotates the device from portrait to landscape mode, or from landscape to portrait mode. A configuration change can also occur when the device language changes or a hardware keyboard is plugged in.
  • When a configuration change occurs, Android invokes all the activity lifecycle's shutdown callbacks. Then Android restarts the activity from scratch, running all the lifecycle startup callbacks.
  • When Android shuts down an app because of a configuration change, it restarts the activity with the state bundle that is available to onCreate().
  • As with process shutdown, save your app's state to the bundle in onSaveInstanceState().

Udacity course:

Android developer documentation:

Other:

This section lists possible homework assignments for students who are working through this codelab as part of a course led by an instructor. It's up to the instructor to do the following:

  • Assign homework if required.
  • Communicate to students how to submit homework assignments.
  • Grade the homework assignments.

Instructors can use these suggestions as little or as much as they want, and should feel free to assign any other homework they feel is appropriate.

If you're working through this codelab on your own, feel free to use these homework assignments to test your knowledge.

Change an app

Open the DiceRoller app from Lesson 1. (You can download the app here if you don't have it.) Compile and run the app, and note that if you rotate the device, the current value of the dice is lost. Implement onSaveInstanceState() to retain that value in the bundle, and restore that value in onCreate().

Answer these questions

Question 1

Your app contains a physics simulation that requires heavy computation to display. Then the user gets a phone call. Which of the following is true?

  • During the phone call, you should continue computing the positions of objects in the physics simulation.
  • During the phone call, you should stop computing the positions of objects in the physics simulation.

Question 2

Which lifecycle method should you override to pause the simulation when the app is not on the screen?

  • onDestroy()
  • onStop()
  • onPause()
  • onSaveInstanceState()

Question 3

To make a class lifecycle-aware through the Android lifecycle library, which interface should the class implement?

  • Lifecycle
  • LifecycleOwner
  • Lifecycle.Event
  • LifecycleObserver

Question 4

Under which circumstances does the onCreate() method in your activity receive a Bundle with data in it (that is, the Bundle is not null)? More than one answer might apply.

  • The activity is restarted after the device is rotated.
  • The activity is started from scratch.
  • The activity is resumed after returning from the background.
  • The device is rebooted.

Start the next lesson: 5.1: ViewModel and ViewModelFactory

For links to other codelabs in this course, see the Android Kotlin Fundamentals codelabs landing page.