An Android Studio Material Design 3 Theming and Dynamic Color Tutorial

This chapter will show you how to create a new Material Design 3 theme using the Material Theme Builder tool and integrate it into an Android Studio project. The tutorial will also demonstrate how to add support for and test dynamic theme colors to an app.

Creating the ThemeDemo Project

Select the New Project option from the welcome screen and, within the resulting new project dialog, choose the Empty Views Activity template before clicking on the Next button.

Enter ThemeDemo into the Name field and specify com.ebookfrenzy.themedemo as the package name. Before clicking on the Finish button, change the Minimum API level setting to API 26: Android 8.0 (Oreo) and the Language menu to Kotlin.

Designing the User Interface

The main activity will consist of a simple layout containing some user interface components that will enable us to see the effects of the theming work performed later in the chapter. For information on MD3 components, refer to the following web page:

https://material.io/blog/migrating-material-3

The layout will be designed within the activity_main.xml file, which currently contains a single Text view. Open this file in the layout editor, delete the Text view, turn off Autoconnect mode (marked A in Figure 92-1), and click on the button to clear all constraints from the layout (B).

Figure 92-1

From the Buttons section of the Palette, drag Chip, CheckBox, Switch, and Button views onto the layout canvas. Next, drag a FloatingActionButton onto the layout canvas to position it beneath the Button component. When prompted to choose an icon to appear on the FloatingActionButton, select the ic_lock_power_off icon from within the resource tool window, as illustrated in Figure 92-2:

Figure 92-2

Change the text attribute for the Chip widget to “This is my chip” and set the chipIcon attribute to @ android:drawable/ic_btn_speak_now so that the layout resembles that shown to the left in Figure 92-3:

Figure 92-3

To set up the constraints, select all the components, right-click on the Chip view, and select Chains -> Create Vertical Chain from the resulting menu. Repeat this step, this time selecting the Center -> Horizontally in Parent menu option.

Compile and run the app on a device or emulator and verify that the user interface matches that shown in Figure 92-3 above. The next step is to create a custom theme and apply it to the project.

Building a New Theme

Begin by opening a browser window and navigating to the following URL to access the builder tool:

https://m3.material.io/theme-builder#/custom

Once you have loaded the builder, select a wallpaper followed by the Custom button at the top of the screen, and then click on the Primary color circle in the Core Colors section to display the color selector. From the color selector, choose any color as the basis for your theme:

Figure 92-4

Review the color scheme in the Your Theme panel and make any necessary color adjustments using the Core colors settings until you are happy with the color slots. Once the theme is ready, click the Export button in the top right-hand corner and select the Android Views (XML) option. When prompted, save the file to a suitable location on your computer filesystem. The theme will be saved as a compressed file named material-theme.zip.

Using the appropriate tool for your operating system, unpack the theme file, which should contain the following folders and files in a folder named material-theme:

  • values/colors.xml – The color definitions.
  • values/themes.xml – The theme for the light mode.
  • values-night/themes.xml – The theme for dark mode.

Now that the theme files have been generated, they need to be integrated into the Android Studio project.

Adding the Theme to the Project

Before adding the new theme to the project, we first need to remove the old theme files. This is easier if the Project tool window is in Project Files mode. To switch mode, use the menu at the top of the tool Project tool window as shown below and select the Project Files option:

Figure 92-5

With Project Files mode selected, navigate to the app -> src -> main -> res -> values folder and select and delete the colors.xml and themes.xml files. Also, delete the themes.xml file located in the values-night folder.

Open the filesystem navigation tool for your operating system, locate the colors.xml and themes.xml files in the values folder of the new material theme, and copy and paste them into the values folder within the Project tool window. Repeat this step to copy the themes.xml file in the values-night folder, this time pasting it into the values-night folder.

Switch the Project tool window back to Android mode, at which point the value resource files section should match Figure 92-6:

Figure 92-6

Next, modify the light themes.xml file to match the current project as follows:

<resources>
    <style name="Base.Theme.ThemeDemo" parent="Theme.Material3.Light.NoActionBar">
        <item name="colorPrimary">@color/md_theme_light_primary</item>
        <item name="colorOnPrimary">@color/md_theme_light_onPrimary</item>
.
.
    </style>
 
    <style name="Theme.ThemeDemo" parent="Base.Theme.ThemeDemo" />
 
</resources>Code language: HTML, XML (xml)

Repeat these steps to make the same modifications to the themes.xml (night) file.

Return to the activity_main.xml file or rerun the app to confirm that the user interface is rendered using the custom theme colors.

Enabling Dynamic Color Support

The app will need to be run on a device or emulator running Android 12 or later with the correct Wallpaper settings to test dynamic colors. On the device or emulator, launch the Settings app and select Wallpaper & style from the list of options. On the wallpaper settings screen, click the option to change the wallpaper (marked A in Figure 92-7) and select a wallpaper image containing colors that differ significantly from the colors in your theme. Once selected, assign the wallpaper to the Home screen.

Return to the Wallpaper & styles screen and make sure that the Wallpaper colors option is selected (B) before choosing an option from the color scheme buttons (C). As each option is clicked, the wallpaper example will change to reflect the selection:

Figure 92-7

To enable dynamic colors, we need to call the applyToActivitiesIfAvailable() method of the DynamicColors class. To enable dynamic color support for the entire app, this needs to be called from within the onCreate() method of a custom Application instance. Begin by adding a new Kotlin class file to the project under app -> java -> com. ebookfrenzy.themedemo named ThemeDemoApplication.kt and modifying it so that it reads as follows:

package com.ebookfrenzy.themedemo
 
import android.app.Application
import com.google.android.material.color.DynamicColors
 
class ThemeDemoApplication: Application() {
    override fun onCreate() {
        super.onCreate()
        DynamicColors.applyToActivitiesIfAvailable(this)
    }
}Code language: Kotlin (kotlin)

With the custom Application class created, we must configure the project to use this class instead of the default Application instance. To do this, edit the AndroidManifest.xml file and add an android:name element referencing the new class:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.ebookfrenzy.themedemo">
 
    <application
        android:name=".ThemeDemoApplication"
        android:allowBackup="true"
.
.Code language: HTML, XML (xml)

Build and run the app and note that the layout uses a theme matching the wallpaper color. Place the ThemeDemo app into the background, return to the Wallpaper & styles settings screen, and choose a different wallpaper. Bring the ThemeDemo app to the foreground again. At this point, it will have dynamically adapted to match the new wallpaper.

Previewing Dynamic Colors

Dynamic color behavior can also be previewed within the Android Studio layout editor. To try this, open the activity_main.xml file, click on the theme menu, and select the More Themes option, as shown in Figure 92-8:

Figure 92-8

Next, use the search field in the theme selection dialog to list dynamic themes:

Figure 92-9

Select the Material3.DynamicColors.DayNight theme before clicking on the OK button. On returning to the layout editor, select the System UI Mode menu and choose one of the wallpaper options as highlighted in Figure 92-10:

Figure 92-10

Once a wallpaper has been selected, the colors of the components in the layout will change accordingly.

Summary

In this chapter, we have used the Material Theme Builder to design a new theme and explained the steps to integrate the generated theme files into an Android Studio project. Finally, the chapter demonstrated how to implement and use the Material You dynamic colors feature.

Working with Material Design 3 Theming in Android Studio

The appearance of an Android app is intended to conform to a set of guidelines defined by Material Design. Google developed Material Design to provide a level of design consistency between different apps while also allowing app developers to include their own branding in terms of color, typography, and shape choices (a concept referred to as Material theming). In addition to design guidelines, Material Design also includes a set of UI components for use when designing user interface layouts, many of which we have used throughout this book.

This chapter will provide an overview of how theming works within an Android Studio project and explore how the default design configurations provided for newly created projects can be modified to meet your branding requirements.

Material Design 2 vs. Material Design 3

Before beginning, it is important to note that Google is transitioning from Material Design 2 to Material Design 3 and that Android Studio Giraffe projects default to Material Design 3. Material Design 3 provides the basis for Material You, a feature introduced in Android 12 that allows an app to automatically adjust theme elements to complement preferences configured by the user on the device. For example, dynamic color support provided by Material Design 3 allows the colors used in apps to adapt automatically to match the user’s wallpaper selection.

Understanding Material Design Theming

We know that Android app user interfaces are created by assembling components such as layouts, text fields, and buttons. These components appear using default colors unless we specifically override a color attribute in the XML layout resource file or by writing code. The project’s theme defines these default colors. The theme consists of a set of color slots (declared in themes.xml files) which are assigned color values (declared in the colors.xml file). Each UI component is programmed internally to use theme color slots as the default color for specific attributes (such as the foreground and background colors of the Text widget). It follows, therefore, that we can change the application-wide theme of an app by changing the colors assigned to specific theme slots. When the app runs, the new default colors will be used for all widgets when the user interface is rendered.

Material Design 3 Theming

Before exploring Material Design 3, we must consider how it is used in an Android Studio project. The theme used by an application project is declared as a property of the application element within the AndroidManifest.xml file, for example:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
 
    <application
.
.
        android:supportsRtl="true"
        android:theme="@style/Theme.MyDemoApp"
        tools:targetApi="31">
        <activity
.
.Code language: HTML, XML (xml)

As previously discussed, all of the files associated with the project theme are contained within the colors.xml and themes.xml files located in the res -> values folder, as shown in Figure 91-1:

Figure 91-1

The theme itself is declared in the two themes.xml files located in the themes folder. These resource files declare different color palettes containing Material Theme color slots for use when the device is in light or dark (night) mode. Note that the style name property in each file must match that referenced in the AndroidManifest.xml file, for example:

<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Base.Theme.MyDemoApp" parent="Theme.Material3.DayNight.NoActionBar">
        <!-- Customize your light theme here. -->
        <!-- <item name="colorPrimary">@color/my_light_primary</item> -->
    </style>
 
    <style name="Theme.MyDemoApp" parent="Base.Theme.MyDemoApp" />
</resources>Code language: HTML, XML (xml)

These color slots (also referred to as color attributes) are used by the Material components to set colors when they are rendered on the screen. For example, the colorPrimary color slot is used as the background color for the Material Button component.

Color slots in MD3 are grouped as Primary, Secondary, Tertiary, Error, Background, and Surface. These slots are further divided into pairs consisting of a base color and an “on” base color. This generally translates to the background and foreground colors of a Material component.

The particular group used for coloring will differ between widgets. A Material Button widget, for example, will use the colorPrimary base color for the background color and colorOnPrimary for its content (i.e., the text or icon it displays). The FloatingActionButton component, on the other hand, uses colorPrimaryContainer as the background color and colorOnPrimaryContainer for the foreground. The correct group for a specific widget type can usually be identified quickly by changing color settings in the theme files and reviewing the rendering in the layout editor.

Suppose that we need to change colorPrimary to red. We achieve this by adding a new entry to the colors.xml file for the red color and then assigning it to the colorPrimary slot in the themes.xml file. The colorPrimary slot in an MD3 theme night, therefore, read as follows:

<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Base.Theme.MyDemoApp" parent="Theme.Material3.DayNight.NoActionBar">
        <item name="colorPrimary">@color/my_bright_primary</item>
    </style>
 
    <style name="Theme.MyDemoApp" parent="Base.Theme.MyDemoApp" />
</resources>
This color is then declared in the colors.xml file:
<?xml version="1.0" encoding="utf-8"?>
<resources>
.
.
    <color name="my_bright_primary">#FC0505</color>
</resources>Code language: HTML, XML (xml)

Building a Custom Theme

As we have seen, the coding work in implementing a theme is relatively simple. The difficult part, however, is often choosing complementary colors to make up the theme. Fortunately, Google has developed a tool that makes it easy to design custom color themes for your apps. This tool is called the Material Theme Builder and is available at:

https://m3.material.io/theme-builder#/custom

On the custom screen (Figure 91-2), make a color selection for the primary color key (A) by clicking on the color circle to display the color selection dialog. Once a color has been selected, the preview (B) will change to reflect the recommended colors for all MD3 color slots, along with example app interfaces and widgets. In addition, you can override the generated colors for the Secondary, Tertiary, and Neutral slots by clicking on the corresponding color circles to display the color selection dialog.

The area marked B displays example app interfaces, light and dark color scheme charts, and widgets that update to preview your color selections. Since the panel is longer than the typical browser window, you must scroll down to see all the information.

To incorporate the theme into your design, click the Export button (C) and select the Android View (XML) option. Once downloaded, the colors.xml and themes.xml files can be used to replace the existing files in your project. Note that the theme name in the two exported themes.xml files must be changed to match your project.

Figure 91-2

Summary

Material Design provides guidelines and components defining how Android apps appear. Individual branding can be applied to an app by designing themes that specify the colors, fonts, and shapes used when displaying the app. Google recently introduced Material Design 3, which replaces Material Design 2 and supports the new features of Material You, including dynamic colors. Google also provides the Material Theme Builder for designing your own themes, which eases the task of choosing complementary theme colors. Once this tool has been used to design a theme, the corresponding files can be exported and used within an Android Studio project.

An Android Studio In-App Purchasing Tutorial

In the previous chapter, we explored how to integrate in-app purchasing into an Android project and also looked at some code samples that can be used when working on your own projects. This chapter will put this theory into practice by creating an example project demonstrating how to add a consumable in-app product to an Android app. The tutorial will also show how in-app products are added and managed within the Google Play Console and explain how to enable test payments to make purchases during testing without having to spend real money.

About the In-App Purchasing Example Project

The simple concept behind this project is an app in which an in-app product must be purchased before a button can be clicked. This in-app product is consumed each time the button is clicked, requiring the user to repurchase the product each time they want to be able to click the button. On initialization, the app will connect to the app store, obtain product details, and display the product name. Once the app has established that the product is available, a purchase button will be enabled, which will step through the purchase process when clicked. Upon completion of the purchase, a second button will be enabled so the user can click on it and consume the purchase.

Creating the InAppPurchase Project

The first step in this exercise is to create a new project. Launch Android Studio and select the New Project option from the welcome screen. Choose the Empty Views Activity template in the new project dialog before clicking the Next button.

Enter InAppPurchase into the Name field and specify a package name that uniquely identifies your app within the Google Play ecosystem (for example, com.<your company>.InAppPurchase). Before clicking on the Finish button, change the Minimum API level setting to API 26: Android 8.0 (Oreo) and the Language menu to Kotlin. Once the project has been created, use the steps outlined in section 18.8 Migrating a Project to View Binding to convert the project to use view binding.

Adding Libraries to the Project

Before writing code, some libraries must be added to the project build configuration, including the standard Android billing client library. Later in the project, we will also need to use the ImmutableList class, part of Google’s Guava Core Java libraries. Add these libraries now by modifying the Gradle Scripts -> build.gradle.kts (Module: app) file with the following changes:

.
.
dependencies {
.
.
    implementation ("com.android.billingclient:billing:6.0.1")
    implementation ("com.android.billingclient:billing-ktx:6.0.1")
    implementation ("com.google.guava:guava:24.1-jre")
    implementation ("com.google.guava:guava:27.0.1-android")
.
.Code language: Gradle (gradle)

Click on the Sync Now link at the top of the editor panel to commit these changes.

Designing the User Interface

The user interface will consist of the existing TextView and two Buttons. With the activity_main.xml file loaded into the editor, drag and drop two Button views onto the layout so that one is above and the other below the TextView. Select the TextView and change the id attribute to statusText.

Click on the Clear all Constraints button in the toolbar and shift-click to select all three views. Right-click on the top-most Button view and select the Center -> Horizontally in Parent menu option. Repeat this step once more, selecting Chains -> Create Vertical Chain. Change the text attribute of the top button so that it reads “Consume Purchase” and the id to consumeButton. Also, configure the onClick property to call a method named consumePurchase.

Select the bottom-most button and repeat the above steps, setting the text to “Buy Product”, the id to buyButton, and the onClick callback to makePurchase. Once completed, the layout should match that shown in Figure 88-1:

Figure 88-1

Adding the App to the Google Play Store

Using the steps outlined in the chapter entitled “Creating, Testing and Uploading an Android App Bundle”, sign into the Play Console, create a new app, and set up a new internal testing track, including the email addresses of designated testers. Return to Android Studio and generate a signed release app bundle for the project. Once the bundle file has been generated, upload it to the internal testing track and roll it out for testing.

Now that the app is in the Google Play Store, we are ready to create an in-app product for the project.

Creating an In-App Product

With the app selected in the Play Console, scroll down the list of options in the left-hand panel until the Monetize section appears. Within this section, select the In-app products option listed under Products, as shown in Figure 88-2:

Figure 88-2

On the In-app products page, click on the Create product button:

Figure 88-3

On the new product screen, enter the following information before saving the new product:

  • Product ID: one_button_click
  • Name: A Button Click
  • Description: This is a test in-app product that allows a button to be clicked once. • Default price: Set to the lowest possible price in your preferred currency.

Enabling License Testers

When testing in-app billing, it is useful to make test purchases without spending any money. This can be achieved by enabling license testing for the internal track testers. License testers can use a test payment card when making purchases so that they are not charged.

Within the Play Console, return to the main home screen and select the Setup -> License testing option:

Figure 88-4

Within the license testing screen, add the testers that were added for the internal testing track, change the License response setting to RESPOND_NORMALLY, and save the changes:

Figure 88-5

Now that the app and the in-app product have been set up in the Play Console, we can add code to the project.

Initializing the Billing Client

Edit the MainActivity.kt file and make the following changes to begin implementing the in-app purchase functionality:

.
.
import android.util.Log
import com.android.billingclient.api.*
.
.
class MainActivity : AppCompatActivity() {
 
    private lateinit var binding: ActivityMainBinding
    private lateinit var billingClient: BillingClient
    private lateinit var productDetails: ProductDetails
    private lateinit var purchase: Purchase
    private val demo_product = "one_button_click"
 
    val TAG = "InAppPurchaseTag"
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        billingSetup()
    }
 
    private fun billingSetup() {
        billingClient = BillingClient.newBuilder(this)
            .setListener(purchasesUpdatedListener)
            .enablePendingPurchases()
            .build()
 
        billingClient.startConnection(object : BillingClientStateListener {
            override fun onBillingSetupFinished(
                billingResult: BillingResult
            ) {
                if (billingResult.responseCode ==
                    BillingClient.BillingResponseCode.OK
                ) {
                    Log.i(TAG, "OnBillingSetupFinish connected")
                    queryProduct(demo_product)
                } else {
                    Log.i(TAG, "OnBillingSetupFinish failed")
                }
            }
 
            override fun onBillingServiceDisconnected() {
                Log.i(TAG, "OnBillingSetupFinish connection lost")
            }
        })
    }
.
.Code language: Kotlin (kotlin)

When the app starts, the onCreate() method will now call billingSetup(), which will, in turn, create a new billing client instance and attempt to connect to the Google Play Billing Library. The onBillingSetupFinished() listener will be called when the connection attempt completes and output Logcat messages indicating the success or otherwise of the connection attempt. Finally, we have also implemented the onBillingServiceDisconnected() callback which will be called if the Google Play Billing Library connection is lost.

If the connection is successful, queryProduct() is called. This method and the purchasesUpdatedListener assigned to the billing client must be added.

Querying the Product

To make sure the product is available for purchase, we need to create a QueryProductDetailsParams instance configured with the product ID that was specified in the Play Console and pass it to the queryProductDetailsAsync() method of the billing client. This will require that we also add the onProductDetailsResponse() callback method, where we will check that the product exists, extract the product name, and display it on the status TextView. Now that we have obtained the product details, we can also safely enable the buy button. Within the MainActivity.kt file, add the queryProduct() method so that it reads as follows:

.
.
import com.android.billingclient.api.QueryProductDetailsParams.Product
import com.google.common.collect.ImmutableList
.
.
private fun queryProduct(productId: String) {
    val queryProductDetailsParams = QueryProductDetailsParams.newBuilder()
        .setProductList(
            ImmutableList.of(
                Product.newBuilder()
                    .setProductId(productId)
                    .setProductType(
                        BillingClient.ProductType.INAPP
                    )
                    .build()
            )
        )
        .build()
 
    billingClient.queryProductDetailsAsync(
        queryProductDetailsParams
    ) { billingResult, productDetailsList ->
        if (productDetailsList.isNotEmpty()) {
            productDetails = productDetailsList[0]
            runOnUiThread {
                binding.statusText.text = productDetails.name
            }
        } else {
            Log.i(TAG, "onProductDetailsResponse: No products")
        }
    }
}Code language: Kotlin (kotlin)

Much of the code used here should be familiar from the previous chapter. The listener code checks that at least one product matches the query criteria. The ProductDetails object is then extracted from the first matching product, stored in the productDetails variable, and the product name property is displayed on the TextView. One point of note is that when we display the product name on the status TextView, we do so by calling runOnUiThread(). This is necessary because the listener is not running on the main thread, so it cannot safely make direct changes to the user interface. The runOnUiThread() method provides a quick and convenient way to execute code on the main thread without using coroutines.

Launching the Purchase Flow

When the user clicks the buy button, makePurchase() will be called to start the purchase process. We can now add this method as follows:

.
.
import com.android.billingclient.api.BillingFlowParams.ProductDetailsParams
import android.view.View
.
.
fun makePurchase(view: View?) {
    val billingFlowParams = BillingFlowParams.newBuilder()
        .setProductDetailsParamsList(
            ImmutableList.of(
                ProductDetailsParams.newBuilder()
                    .setProductDetails(productDetails)
                    .build()
            )
        )
        .build()
 
    billingClient.launchBillingFlow(this, billingFlowParams)
}Code language: Kotlin (kotlin)

Handling Purchase Updates

The results of the purchase process will be reported to the app via the PurchaseUpdatedListener assigned to the billing client during the initialization phase. Add this handler now as follows:

private val purchasesUpdatedListener =
    PurchasesUpdatedListener { billingResult, purchases ->
        if (billingResult.responseCode ==
            BillingClient.BillingResponseCode.OK
            && purchases != null
        ) {
            for (purchase in purchases) {
                completePurchase(purchase)
            }
        } else if (billingResult.responseCode ==
            BillingClient.BillingResponseCode.USER_CANCELED
        ) {
            Log.i(TAG, "onPurchasesUpdated: Purchase Canceled")
        } else {
            Log.i(TAG, "onPurchasesUpdated: Error")
        }
    }Code language: Kotlin (kotlin)

The handler will output log messages if the user cancels the purchase or another error occurs. However, a successful purchase results in a call to the completePurchase() method, which is passed the current Purchase object. Add this method as outlined below:

private fun completePurchase(item: Purchase) {
    purchase = item
    if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
        runOnUiThread {
            binding.consumeButton.isEnabled = true
            binding.statusText.text = "Purchase Complete"
            binding.buyButton.isEnabled = false
        }
    }
}Code language: Kotlin (kotlin)

This method stores the purchase before verifying that the product has indeed been purchased and that payment is not still pending. The “consume” button is enabled, and the user is notified of the successful purchase. The buy button is also disabled to prevent the user from repurchasing before consuming the purchase.

Consuming the Product

With the user now able to click on the “consume” button, the next step is to ensure the product is consumed so that only one click can be performed before another button click is purchased. This requires that we now write the consumePurchase() method:

.
.
import kotlinx.coroutines.*
.
.
class MainActivity : AppCompatActivity() {
 
    private val coroutineScope = CoroutineScope(Dispatchers.IO)
.
.
    fun consumePurchase(view: View?) {
 
        val consumeParams = ConsumeParams.newBuilder()
            .setPurchaseToken(purchase.purchaseToken)
            .build()
 
         coroutineScope.launch {
             val result = billingClient.consumePurchase(consumeParams) 
             if (result.billingResult.responseCode == 
                      BillingClient.BillingResponseCode.OK) {
                 runOnUiThread() {
                     binding.consumeButton.isEnabled = false
                     binding.statusText.text = "Purchase consumed"
                     binding.buyButton.isEnabled = true
                 }
             }
         }
    }
.
.Code language: Kotlin (kotlin)

This method creates a ConsumeParams instance and configures it with the purchase token for the current purchase (obtained from the Purchase object previously saved in the completePurchase() method). This is passed to the consumePurchase() method, which is launched within a coroutine using the IO dispatcher. If the product is successfully consumed, code is executed in the main thread to disable the consume button, enable the buy button, and update the status text.

Restoring a Previous Purchase

With the code added so far, we can purchase and consume a product within a single session. If we were to make a purchase and exit the app before consuming it, the purchase would be lost when the app restarts. We can solve this problem by configuring a QueryPurchasesParams instance to search for the unconsumed In-App product and passing it to the queryPurchasesAsync() method of the billing client together with a reference to a listener that will be called with the results. Add a new function and the listener to the MainActivity.kt file as follows:

private fun reloadPurchase() {
 
    val queryPurchasesParams = QueryPurchasesParams.newBuilder()
        .setProductType(BillingClient.ProductType.INAPP)
        .build()
 
    billingClient.queryPurchasesAsync(
        queryPurchasesParams,
            purchasesListener
    )
}
 
private val purchasesListener =
    PurchasesResponseListener { billingResult, purchases ->
        if (purchases.isNotEmpty()) {
            purchase = purchases.first()
            binding.consumeButton.isEnabled = true
            binding.buyButton.isEnabled = false
        } else {
            binding.consumeButton.isEnabled = false
        }
    }Code language: Kotlin (kotlin)

If the list of purchases passed to the listener is not empty, the first purchase in the list is assigned to the purchase variable, and the consume button is enabled (in a more complete implementation, code should be added to check this is the correct product by comparing the product id and to handle the return of multiple purchases). If no purchases are found, the consume button is disabled until another purchase is made. All that remains is to call our new reloadPurchase() method during the billing setup process as follows:

private fun billingSetup() {
.
.
            if (billingResult.responseCode ==
                BillingClient.BillingResponseCode.OK
            ) {
                Log.i(TAG, "OnBillingSetupFinish connected")
                queryProduct(demo_product)
                reloadPurchase()
            } else {
                Log.i(TAG, "OnBillingSetupFinish failed")
            }
.
.
}Code language: Kotlin (kotlin)

Testing the App

Before we can test the app, we need to upload this latest version to the Play Console. As we already have version 1 uploaded, we need to increase the version number in the build.gradle.kts (Module: app) file:

.
.
defaultConfig {
    applicationId "com.ebookfrenzy.inapppurchase"
    minSdk 26
    targetSdk 32
    versionCode 2
    versionName "2.0"
.
.Code language: Gradle (gradle)

Sync the build configuration, then follow the steps in the Creating, Testing, and Uploading an Android App Bundle chapter to generate a new app bundle, upload it to the internal test track, and roll it out to the testers. Next, using the internal testing link, install the app on a device or emulator where one of the test accounts is signed in. To locate the testing link, select the app in the Google Play Console and choose the Internal testing option from the navigation panel followed by the Testers tab, as shown in Figure 88-6:

Figure 88-6

Scroll to the “How testers join your test” section of the screen and click on Copy link:

Figure 88-7

Open the Chrome browser on the testing device or emulator, enter the testing link, and follow the instructions to install the app from the Play Store. After the app starts, it should, after a short delay, display the product name on the TextView. Clicking the buy button will begin the purchase flow, as shown in Figure 88-8:

Figure 88-8

Tap the buy button to complete the purchase using the test card and wait for the Consume Purchase button to be enabled. Before tapping this button, attempt to purchase the product again and verify that it is not possible because you already own the product.

Tap the Consume Purchase button and wait for the “Purchase consumed” message to appear on the TextView. With the product consumed, it should now be possible to purchase it again. Make another purchase, then terminate and restart the app. The app should locate the previous unconsumed purchase and enable the consume button.

Troubleshooting

If you encounter problems with the purchase, make sure the device is attached to Android Studio via a USB cable or WiFi, and select it from within the Logcat panel. Enter InAppPurchaseTag into the Logcat search bar and check the diagnostic output, adding additional Log calls in the code if necessary. For additional information about failures, a useful trick is to access the debug message from BillingResult instances, for example:

.
.
} else if (billingResult.responseCode ==
    BillingClient.BillingResponseCode.USER_CANCELED
) {
    Log.i(TAG, "onPurchasesUpdated: Purchase Canceled")
} else {
    Log.i(TAG, billingResult.getDebugMessage())
}Code language: Kotlin (kotlin)

Note that as long as you leave the app version number unchanged in the module-level build.gradle.kts file, you should now be able to run modified versions of the app directly on the device or emulator without having to re-bundle and upload it to the console.

If the test payment card is not listed, ensure the device user account has been added to the license testers list. If the app is running on a physical device, try it on an emulator. If all else fails, you can enter a valid payment method to make test purchases and then refund yourself using the Order Management screen accessible from the Play Console home page.

Summary

In this chapter, we created a project demonstrating adding an in-app product to an Android app. This included the creation of the product within the Google Play Console and writing code to initialize and connect to the billing client, querying available products, and purchasing and consuming the product. We also explained how to add license testers using the Play Console to make purchases during testing without spending money.

How to Add Android In-App Billing using Kotlin

In the early days of mobile applications for operating systems such as Android and iOS, the most common method for earning revenue was to charge an upfront fee to download and install the application. Another revenue opportunity was soon introduced in the form of embedding advertising within applications. Perhaps the most common and lucrative option is now to charge the user for purchasing items from within the application after it has been installed. This typically takes the form of access to a higher level in a game, acquiring virtual goods or currency, or subscribing to premium content in the digital edition of a magazine or newspaper.

Google provides support for the integration of in-app purchasing through the Google Play In-App Billing API and the Play Console. This chapter will provide an overview of in-app billing and outline how to integrate in-app billing into your Android projects. Once these topics have been explored, the next chapter will walk you through creating an example app that includes in-app purchasing features.

Preparing a Project for In-App Purchasing

Building in-app purchasing into an app will require a Google Play Developer Console account, details of which were covered previously in the Creating, Testing, and Uploading an Android App Bundle chapter. You will also need to register a Google merchant account and configure your payment settings. These settings can be found by navigating to Setup -> Payments profile in the Play Console. Note that merchant registration is not available in all countries. For details, refer to the following page:

https://support.google.com/googleplay/android-developer/answer/9306917

The app will then need to be uploaded to the console and enabled for in-app purchasing. The console will not activate in-app purchasing support for an app, however, unless the Google Play Billing Library has been added to the module-level build.gradle file. When working with Kotlin, the Google Play Kotlin Extensions Library is also recommended:

dependencies {
.
.
    implementation 'com.android.billingclient:billing:'
    implementation 'com.android.billingclient:billing-ktx:'
.
.
}Code language: JavaScript (javascript)

Once the build file has been modified and the app bundle uploaded to the console, the next step is to add in-app products or subscriptions for the user to purchase.

Creating In-App Products and Subscriptions

Products and subscriptions are created and managed using the options listed beneath the Monetize section of the Play Console navigation panel as highlighted in Figure 83-1 below:

Figure 1-1

Each product or subscription needs an ID, title, description, and pricing information. Purchases fall into the categories of consumable (the item must be purchased each time it is required by the user such as virtual currency in a game), non-consumable (only needs to be purchased once by the user such as content access), and subscription-based. Consumable and non-consumable products are collectively referred to as managed products.

Subscriptions are useful for selling an item that needs to be renewed on a regular schedule such as access to news content or the premium features of an app. When creating a subscription, a base plan is defined specifying the price, renewal period (monthly, annually, etc.), and whether the subscription auto-renews. Users can also be provided with discount offers and given the option of pre-purchasing a subscription.

Billing Client Initialization

Communication between your app and the Google Play Billing Library is handled by a BillingClient instance. In addition, BillingClient includes a set of methods that can be called to perform both synchronous and asynchronous billing-related activities. When the billing client is initialized, it will need to be provided with a reference to a PurchasesUpdatedListener callback handler. The client will call this handler to notify your app of the results of any purchasing activity. To avoid duplicate notifications, it is recommended to have only one BillingClient instance per app.

A BillingClient instance can be created using the newBuilder() method, passing through the current activity or fragment context. The purchase update handler is then assigned to the client via the setListener() method:

private val purchasesUpdatedListener =
    PurchasesUpdatedListener { billingResult, purchases ->
        if (billingResult.responseCode ==
            BillingClient.BillingResponseCode.OK
            && purchases != null
        ) {
            for (purchase in purchases) {
                // Process the purchases
            }
        } else if (billingResult.responseCode ==
            BillingClient.BillingResponseCode.USER_CANCELED
        ) {
            // Purchase cancelled by user
        } else {
            // Handle errors here
        }
    }
 
billingClient = BillingClient.newBuilder(this)
    .setListener(purchasesUpdatedListener)
    .enablePendingPurchases()
    .build()Code language: JavaScript (javascript)

Connecting to the Google Play Billing Library

After the successful creation of the Billing Client, the next step is to initialize a connection to the Google Play Billing Library. To establish this connection, a call needs to be made to the startConnection() method of the billing client instance. Since the connection is performed asynchronously, a BillingClientStateListener handler needs to be implemented to receive a callback indicating whether the connection was successful. Code should also be added to override the onBillingServiceDisconnected() method. This is called if the connection to the Billing Library is lost and can be used to report the problem to the user and retry the connection.

Once the setup and connection tasks are complete, the BillingClient instance will make a call to the onBillingSetupFinished() method which can be used to check that the client is ready:

billingClient.startConnection(object : BillingClientStateListener {
    override fun onBillingSetupFinished(
        billingResult: BillingResult
    ) {
        if (billingResult.responseCode ==
            BillingClient.BillingResponseCode.OK
        ) {
            // Connection successful
        } else {
            // Connection failed 
        }
    }
 
    override fun onBillingServiceDisconnected() {
        // Connection to billing service lost
    }
})Code language: JavaScript (javascript)

Querying Available Products

Once the billing environment is initialized and ready to go, the next step is to request the details of the products or subscriptions that are available for purchase. This is achieved by making a call to the queryProductDetailsAsync() method of the BillingClient and passing through an appropriately configured QueryProductDetailsParams instance containing the product ID and type (ProductType.SUBS for a subscription or ProductType.INAPP for a managed product):

val queryProductDetailsParams = QueryProductDetailsParams.newBuilder()
    .setProductList(
        ImmutableList.of(
            QueryProductDetailsParams.Product.newBuilder()
                .setProductId(productId)
                .setProductType(
                    BillingClient.ProductType.INAPP
                )
                .build()
        )
    )
    .build()
 
billingClient.queryProductDetailsAsync(
    queryProductDetailsParams
) { billingResult, productDetailsList ->
    if (!productDetailsList.isEmpty()) {
        // Process list of matching products
    } else {
        // No product matches found
    }
}Code language: JavaScript (javascript)

The queryProductDetailsAsync() method is passed a ProductDetailsResponseListener handler (in this case in the form of a lambda code block) which, in turn, is called and passed a list of ProductDetail objects containing information about the matching products. For example, we can call methods on these objects to get information such as the product name, title, description, price, and offer details.

Starting the Purchase Process

Once a product or subscription has been queried and selected for purchase by the user, the purchase process is ready to be launched. We do this by calling the launchBillingFlow() method of the BillingClient, passing through as arguments the current activity and a BillingFlowParams instance configured with the ProductDetail object for the item being purchased.

val billingFlowParams = BillingFlowParams.newBuilder()
    .setProductDetailsParamsList(
        ImmutableList.of(
            BillingFlowParams.ProductDetailsParams.newBuilder()
                .setProductDetails(productDetails)
                .build()
        )
    )
    .build()
 
billingClient.launchBillingFlow(this, billingFlowParams)Code language: JavaScript (javascript)

The success or otherwise of the purchase operation will be reported via a call to the PurchasesUpdatedListener callback handler outlined earlier in the chapter.

Completing the Purchase

When purchases are successful, the PurchasesUpdatedListener handler will be passed a list containing a Purchase object for each item. You can verify that the item has been purchased by calling the getPurchaseState() method of the Purchase instance as follows:

if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) {
    // Purchase completed. 
} else if (purchase.getPurchaseState() == Purchase.PurchaseState.PENDING) {
    // Payment is still pending
}Code language: JavaScript (javascript)

Note that your app will only support pending purchases if a call is made to the enablePendingPurchases() method during initialization. A pending purchase will remain so until the user completes the payment process.

When the purchase of a non-consumable item is complete, it will need to be acknowledged to prevent a refund from being issued to the user. This requires the purchase token for the item which is obtained via a call to the getPurchaseToken() method of the Purchase object. This token is used to create an AcknowledgePurchaseParams instance together with an AcknowledgePurchaseResponseListener handler. Managed product purchases and subscriptions are acknowledged by calling the BillingClient’s acknowledgePurchase() method as follows:

billingClient.acknowledgePurchase(acknowledgePurchaseParams, 
                             acknowledgePurchaseResponseListener);
val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder()
    .setPurchaseToken(purchase.purchaseToken)
    .build()
 
val acknowledgePurchaseResponseListener = AcknowledgePurchaseResponseListener {
    // Check acknowledgement result
}
 
billingClient.acknowledgePurchase(
    acknowledgePurchaseParams,
    acknowledgePurchaseResponseListener
)Code language: JavaScript (javascript)

For consumable purchases, you will need to notify Google Play when the item has been consumed so that it is available to be repurchased by the user. This requires a configured ConsumeParams instance containing a purchase token and a call to the billing client’s consumePurchase() method:

val consumeParams = ConsumeParams.newBuilder()
    .setPurchaseToken(purchase.purchaseToken)
    .build()
 
coroutineScope.launch {
    val result = billingClient.consumePurchase(consumeParams)
 
    if (result.billingResult.responseCode == 
                     BillingClient.BillingResponseCode.OK) {
        // Purchase successfully consumed
    }
}Code language: JavaScript (javascript)

Querying Previous Purchases

When working with in-app billing it is a common requirement to check whether a user has already purchased a product or subscription. A list of all the user’s previous purchases of a specific type can be generated by calling the queryPurchasesAsync() method of the BillingClient instance and implementing a PurchaseResponseListener. The following code, for example, obtains a list of all previously purchased items that have not yet been consumed:

val queryPurchasesParams = QueryPurchasesParams.newBuilder()
    .setProductType(BillingClient.ProductType.INAPP)
    .build()
 
billingClient.queryPurchasesAsync(
    queryPurchasesParams,
    purchasesListener
)
.
.
private val purchasesListener =
    PurchasesResponseListener { billingResult, purchases ->
 
        if (!purchases.isEmpty()) {
            // Access existing active purchases
        } else {
            // No 
        }
    }Code language: PHP (php)

To obtain a list of active subscriptions, change the ProductType value from INAPP to SUBS.

Alternatively, to obtain a list of the most recent purchases for each product, make a call to the BillingClient queryPurchaseHistoryAsync() method:

val queryPurchaseHistoryParams = QueryPurchaseHistoryParams.newBuilder()
    .setProductType(BillingClient.ProductType.INAPP)
    .build()
 
billingClient.queryPurchaseHistoryAsync(queryPurchaseHistoryParams) { billingResult, historyList ->
    // Process purchase history list
}Code language: JavaScript (javascript)

Summary

In-app purchases provide a way to generate revenue from within Android apps by selling virtual products and subscriptions to users. In this chapter, we have explored managed products and subscriptions and explained the difference between consumable and non-consumable products. In-app purchasing support is added to an app using the Google Play In-app Billing Library and involves creating and initializing a billing client on which methods are called to perform tasks such as making purchases, listing available products, and consuming existing purchases. The next chapter contains a tutorial demonstrating the addition of in-app purchases to an Android Studio project.

An Android Studio Biometric Tutorial

Touch sensors are now built into many Android devices to identify the user and provide access to the device and application functionality, such as in-app payment options using fingerprint recognition. Fingerprint recognition is just one of several authentication methods, including passwords, PINs, and, more recently, facial recognition.

Although only a few Android devices currently on the market provide facial recognition, this will likely become more common in the future. In recognition of this, Google has begun to transition away from a fingerprint-centric approach to adding authentication to apps to a less specific approach called biometric authentication.

This chapter provides an overview of biometric authentication and a detailed, step-by-step tutorial demonstrating a practical approach to implementing biometric authentication within an Android app project.

An Overview of Biometric Authentication

The key biometric authentication component is the BiometricPrompt class. This class performs much of the work that previously had to be performed by writing code in earlier Android versions, including displaying a standard dialog to guide the user through the authentication process, performing the authentication, and reporting the results to the app. The class also handles excessive failed authentication attempts and enforces a timeout before the user can try again.

The BiometricPrompt class includes a companion Builder class that can be used to configure and create BiometricPrompt instances, including defining the text that is to appear within the biometric authentication dialog and the customization of the cancel button (also referred to as the negative button) that appears in the dialog.

The BiometricPrompt instance is also assigned a set of authentication callbacks that will be called to provide the app with the results of an authentication operation. A CancellationSignal instance is also used to allow the app to cancel the authentication while it is in process.

With these basics covered, the remainder of this chapter will implement fingerprint-based biometric authentication within an example project.

Creating the Biometric Authentication Project

Select the New Project option from the welcome screen and, within the resulting new project dialog, choose the Empty Views Activity template before clicking on the Next button.

Enter BiometricDemo into the Name field and specify com.ebookfrenzy.biometricdemo as the package name. Before clicking on the Finish button, change the Minimum API level setting to API 29: Android (Q) and the Language menu to Kotlin.

Configuring Device Fingerprint Authentication

Fingerprint authentication is only available on devices containing a touch sensor and on which the appropriate configuration steps have been taken to secure the device and enroll at least one fingerprint. For steps on configuring an emulator session to test fingerprint authentication, refer to the chapter Using and Configuring the Android Studio AVD Emulator.

Configure fingerprint authentication on a physical device by opening the Settings app and selecting the Security option. Within the Security settings screen, select the Fingerprint option. Tap the Next button on the resulting information screen to proceed to the Fingerprint setup screen. Before fingerprint security can be enabled, a backup screen unlocking method (such as a PIN) must be configured. If the lock screen is not secured, follow the steps to configure PIN, pattern, or password security.

With the lock screen secured, proceed to the fingerprint detection screen and touch the sensor when prompted lock screen secured, proceed to the fingerprint detection screen and touch the sensor when prompted (Figure 85-1), repeating the process to add additional fingerprints if required.

Figure 85-1

Adding the Biometric Permission to the Manifest File

Biometric authentication requires that the app request the USE_BIOMETRIC permission within the project manifest file. Within the Android Studio Project tool window, locate and edit the app -> manifests -> AndroidManifest.xml file to add the permission request as follows:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.ebookfrenzy.biometricdemo">
 
    <uses-permission
        android:name="android.permission.USE_BIOMETRIC" />
.
.Code language: HTML, XML (xml)

Designing the User Interface

To keep the example as simple as possible, the only visual element within the user interface will be a Button view. Locate and select the activity_main.xml layout resource file to load it into the Layout Editor tool.

Delete the sample TextView object, drag and drop a Button object from the Common category of the palette and position it in the center of the layout canvas. Using the Attributes tool window, change the text property on the button to “Authenticate” and extract the string to a resource. Finally, configure the onClick property to call a method named authenticateUser.

On completion of the above steps, the layout should match that shown in Figure 85-2:

Figure 85-2

Adding a Toast Convenience Method

At various points throughout the code in this example, the app will be designed to display information to the user via Toast messages. Rather than repeat the same Toast code multiple times, a convenience method named notifyUser() will be added to the main activity. This method will accept a single String value and display it to the user as a Toast message. Edit the MainActivity.kt file now and add this method as follows:

.
.
import android.widget.Toast
.
.
private fun notifyUser(message: String) {
    Toast.makeText(this,
            message,
            Toast.LENGTH_LONG).show()
}Code language: Kotlin (kotlin)

Checking the Security Settings

Earlier in this chapter, steps were taken to configure the lock screen and register fingerprints on the device or emulator on which the app will be tested. It is important, however, to include defensive code in the app to ensure these requirements have been met before attempting to seek fingerprint authentication. These steps will be performed within the onCreate method residing in the MainActivity.kt file, using the Keyguard and PackageManager manager services. Note that code has also been added to verify that the USE_BIOMETRIC permission has been configured for the app:

.
.
import androidx.core.app.ActivityCompat
import android.Manifest
import android.app.KeyguardManager
import android.content.Context
import android.content.pm.PackageManager
.
.
class MainActivity : AppCompatActivity() {
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_biometric_demo)
 
        checkBiometricSupport()
    }
 
    private fun checkBiometricSupport(): Boolean {
 
        val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) 
                                          as KeyguardManager
 
        if (!keyguardManager.isKeyguardSecure) {
 
            notifyUser("Lock screen security not enabled in Settings")
            return false
        }
 
        if (ActivityCompat.checkSelfPermission(this,
                        Manifest.permission.USE_BIOMETRIC) != 
                                PackageManager.PERMISSION_GRANTED) {
 
            notifyUser("Fingerprint authentication permission not enabled")
            return false
        }
 
        return if (packageManager.hasSystemFeature(
                    PackageManager.FEATURE_FINGERPRINT)) {
            true
        } else true
    }
.
.
}Code language: Kotlin (kotlin)

The above code changes begin by using the Keyguard manager to verify that a backup screen unlocking method has been configured (in other words, a PIN or other authentication method can be used as an alternative to fingerprint authentication to unlock the screen). If the lock screen is not secured, the code reports the problem to the user and returns from the method.

The method then checks that the user has biometric authentication permission enabled for the app before using the package manager to verify that fingerprint authentication is available on the device.

Configuring the Authentication Callbacks

When the biometric prompt dialog is configured, it will need to be assigned a set of authentication callback methods that can be called to notify the app of the success or failure of the authentication process. These methods need to be wrapped in a BiometricPrompt.AuthenticationCallback class instance. Remaining in the MainActivity.kt file, add a method to create and return an instance of this class with the appropriate methods implemented:

.
.
import android.hardware.biometrics.BiometricPrompt
.
.
private val authenticationCallback: BiometricPrompt.AuthenticationCallback
    get() = object : BiometricPrompt.AuthenticationCallback() {
        override fun onAuthenticationError(errorCode: Int, 
                                           errString: CharSequence) {
            super.onAuthenticationError(errorCode, errString)
            notifyUser("Authentication error: $errString")
        }
 
        override fun onAuthenticationHelp(helpCode: Int, 
                                 helpString: CharSequence) {
            super.onAuthenticationHelp(helpCode, helpString)
        }
 
        override fun onAuthenticationFailed() {
            super.onAuthenticationFailed()
            notifyUser("Authentication Failed")
        }
 
        override fun onAuthenticationSucceeded(result: 
                     BiometricPrompt.AuthenticationResult) {
            super.onAuthenticationSucceeded(result)
            notifyUser("Authentication Succeeded")
 
        }
    }
.
.Code language: Kotlin (kotlin)

Adding the CancellationSignal

Once initiated, the biometric authentication process is performed independently of the app. To provide the app with a way to cancel the operation, an instance of the CancellationSignal class is created and passed to the biometric authentication process. This CancellationSignal instance can then be used to cancel the process if necessary. The cancellation signal instance may be configured with a listener, which will be called when the cancellation is completed. Add a new method to the activity class to configure and return a CancellationSignal object as follows:

.
.
import android.os.CancellationSignal
.
.
private var cancellationSignal: CancellationSignal? = null
.
.
private fun getCancellationSignal(): CancellationSignal {
 
    cancellationSignal = CancellationSignal()
    cancellationSignal?.setOnCancelListener {
        notifyUser("Cancelled via signal")
    }
    return cancellationSignal as CancellationSignal
}
.
.Code language: Kotlin (kotlin)

Starting the Biometric Prompt

All that remains is to add code to the authenticateUser() method to create and configure a BiometricPrompt instance and initiate the authentication. Add the authenticateUser() method as follows:

.
.
import android.view.View
.
.
fun authenticateUser(view: View) {
    val biometricPrompt = BiometricPrompt.Builder(this)
            .setTitle("Biometric Demo")
            .setSubtitle("Authentication is required to continue")
            .setDescription("This app uses biometric authentication to protect your data.")
            .setNegativeButton("Cancel", this.mainExecutor)
                { _, _ ->
                    notifyUser("Authentication cancelled") }.build()
 
    biometricPrompt.authenticate(getCancellationSignal(), mainExecutor, 
                         authenticationCallback)
}Code language: Kotlin (kotlin)

The BiometricPrompt.Builder class creates a new BiometricPrompt instance configured with title, subtitle, and description text to appear in the prompt dialog. The negative button is configured to display text which reads “Cancel” and a listener is configured to display a message when this button is clicked. Finally, the authenticate() method of the BiometricPrompt instance is called and passed the AuthenticationCallback and CancellationSignal instances. The Biometric prompt also needs to know which thread to perform the authentication on. This is defined by passing through an Executor object configured for the required thread. In this case, the getMainExecutor() method is used to pass a main Executor object to the BiometricPrompt instance so that the authentication process occurs on the app’s main thread.

Testing the Project

With the project now complete, run the app on a physical Android device or emulator session and click on the Authenticate button to display the BiometricPrompt dialog as shown in Figure 85-3:

Figure 85-3

Once running, either touch the fingerprint sensor or use the extended controls panel within the emulator to simulate a fingerprint touch as outlined in the chapter entitled Using and Configuring the Android Studio AVD Emulator. Assuming a registered fingerprint is detected, the prompt dialog will return to the main activity, where the toast message from the successful authentication callback method will appear.

Click the Authenticate button again, using an unregistered fingerprint to attempt the authentication. This time the biometric prompt dialog will indicate that the fingerprint was not recognized:

Figure 85-4

Verify that the error handling callback works by clicking on the activity outside the biometric prompt dialog. The prompt dialog will disappear, and the toast message will appear with the following message:

Authentication error: Fingerprint operation cancelled by user.Code language: plaintext (plaintext)

Check that canceling the prompt dialog using the Cancel button triggers the “Authentication Canceled” toast message. Finally, attempt to authenticate multiple times using an unregistered fingerprint and note that after several attempts, the prompt dialog indicates that too many failures have occurred and that future attempts cannot be made until later.

Summary

This chapter has outlined how to integrate biometric authentication into an Android app project. This involves using the BiometricPrompt class, which automatically handles most of the authentication process once configured with appropriate message text and callbacks.

Migrating from Material Design 2 to Material Design 3

This chapter will demonstrate how to migrate an Android Studio project from Material Design 2 to Material Design 3 and integrate a custom theme generated using the Material Theme Builder tool.

Creating the ThemeMigration Project

Select the New Project option from the welcome screen and, within the resulting new project dialog, choose the Empty Activity template before clicking on the Next button.

Enter ThemeMigration into the Name field and specify com.ebookfrenzy.thememigration as the package name. Before clicking on the Finish button, change the Minimum API level setting to API 26: Android 8.0 (Oreo) and the Language menu to Kotlin.

Designing the User Interface

The main activity will consist of a simple layout containing some of the user interface components that will enable us to see the effect of both switching to Material Design 3, and the theming work performed later in the chapter. For information on MD3 components, refer to the following web page:

https://material.io/blog/migrating-material-3

The layout will be designed within the activity_main.xml file which currently contains a single Text view. Open this file in the layout editor, delete the Text view, disable Autoconnect mode (marked A in Figure 94-1), and click on the button to clear all constraints from the layout (B).

Figure 94-1

From the Buttons section of the Palette, drag and drop Chip, CheckBox, Switch, and Button views onto the layout canvas. Next, drag a FloatingActionButton onto the layout canvas so that it is positioned beneath the Button component. When prompted to choose an icon to appear on the FloatingActionButton, select the ic_ lock_power_off icon from within the resource tool window as illustrated in Figure 94-2:

Figure 94-2

Change the text attribute for the Chip widget to “This is my chip” and set the chipIcon attribute to @ android:drawable/ic_btn_speak_now so that the layout resembles that shown to the left in Figure 94-3:

Figure 94-3

Migrating from Material Design 2 to Material Design 3 To set up the constraints shown in the blueprint view in the figure above, select all of the components, right-click on the Chip view, and select Chains -> Create Vertical Chain from the resulting menu. Repeat this step, this time selecting the Center -> Horizontally in Parent menu option.

Compile and run the app on a device or emulator and verify that the user interface matches that shown in Figure 94-3 above. The layout is presented using the Material Design 2 components. The next step is to migrate the theme to Material Design 3.

Migrating to Material Design 3

To switch to Material Design 3 components, all we need to do is edit the two themes.xml files located under app – > res -> values -> themes. The name property of the style element in the first themes.xml needs to be changed as follows:

<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Theme.ThemeMigration" parent="Theme.Material3.Light">

Similarly, make the following change to the themes.xml (night) file to migrate to MD3 for dark mode:

<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Theme.ThemeMigration" parent="Theme.Material3.Dark">

After making the changes, run the app once again and note the differences in appearance between MD2 and MD3 as shown in Figure 94-4 below:

Figure 94-4

Now that we have migrated the project to MD3, the next step is to create a custom theme and apply it to the project.

Building a New Theme

If you completed the previous chapter (“A Material Design 3 Theming and Dynamic Color Tutorial”) you can use the custom theme created in that chapter. If you do not yet have a custom theme, open a browser window and navigate to the following URL to access the builder tool:

https://material-foundation.github.io/material-theme-builder/

Once you have loaded the builder, select the Custom button at the top of the screen and then click on the Primary color circle in the Core Colors section to display the color selector. From the color selector, choose any color as the basis for your theme:

Figure 94-5

Review the color scheme in the Your Theme panel and make any necessary color adjustments using the Core Color settings until you are happy with the color slots. Once the theme is ready, click on the Export button in the top right-hand corner and select the Android Views (XML) option. When prompted, save the file to a suitable location on your computer filesystem. The theme will be saved as a compressed file named material-theme.zip.

Using the appropriate tool for your operating system, unpack the theme file which should contain the following folders and files in a folder named material-theme:

  • values/colors.xml – The color definitions.
  • values/themes.xml – The theme for the light mode.
  • values-night/themes.xml – The theme for dark mode.

Now that the theme files have been generated, they need to be integrated into the Android Studio project.

Adding the Theme to the Project

Before we can add the new theme to the project we first need to remove the old MD2 theme files. This is easier to do if the Project tool window is in Project Files mode. To switch mode, use the menu at the top of the tool Project tool window as shown below and select the Project Files option:

Figure 94-6

With Project Files mode selected, navigate to the app -> src -> main -> res -> values folder and select and delete the colors.xml and themes.xml files. Also, delete the themes.xml file located in the values-night folder.

Open the filesystem navigation tool for your operating system, locate the colors.xml and themes.xml files in the values folder of the new material theme and copy and paste them into the values folder within the Project tool window. Repeat this step to copy the themes.xml file located in the values-night folder, this time pasting into the values-night folder.

Switch the Project tool window back to Android mode, at which point the value resource files section should match Figure 94-7:

Figure 94-7

Review the two themes.xml files and if the colorInversePrimary and colorShadow items are displayed in red indicating they are unresolved, delete these lines from the file before continuing. Next, modify the light themes.xml file to match the current project as follows:

<resources>
    <style name="Base.Theme.ThemeMigration" parent="Theme.Material3.Light.NoActionBar">
        <item name="colorPrimary">@color/md_theme_light_primary</item>
        <item name="colorOnPrimary">@color/md_theme_light_onPrimary</item>
.
.
    </style>

    <style name="Theme.ThemeMigration" parent="Base.Theme.ThemeMigration" />

</resources>

Repeat these steps to make the same modifications to the themes.xml (night) file.

Return to the activity_main.xml file or run the app once again to confirm that the user interface is now rendered using the custom theme colors.

Summary

In this chapter, we have demonstrated how to migrate an Android Studio project from Material Design 2 to Material Design 3. The project also made use of the Material Theme Builder to design a custom theme and explained the steps to integrate the generated theme files into the legacy project.

An Android SharedFlow Tutorial

The previous chapter introduced Kotlin flows and explored how these can be used to return multiple sequential values from within coroutine-based asynchronous code. In this tutorial, we will look at a more detailed flow implementation, this time using SharedFlow within a ViewModel. The tutorial will also demonstrate how to ensure that flow collection responds correctly to an app switching between background and foreground modes.

About the Project

The app created in this chapter will consist of a RecyclerView located in the user interface layout of the main fragment. A shared flow located within a ViewModel will be activated as soon as the view model is created and will emit an integer value every two seconds. Code within the main fragment will collect the values from the flow and list them in the RecyclerView. The project will then be modified to suspend the collection process while the app is placed in the background.

Creating the SharedFlowDemo Project

Begin by launching Android Studio, selecting the New Project option from the welcome screen and, within the resulting new project dialog, choosing the Fragment + ViewModel template before clicking on the Next button.

Enter SharedFlowDemo into the Name field and specify com.ebookfrenzy.sharedflowdemo as the package name. Before clicking on the Finish button, change the Minimum API level setting to API 26: Android 8.0 (Oreo) and the Language menu to Kotlin. Edit the build.gradle (Module: SharedFlowDemo.app) file to enable view binding:

android {
 
    buildFeatures {
        viewBinding true
    }
.
.Code language: Kotlin (kotlin)

Remaining in the build.gradle file, add the Kotlin lifecycle library to the dependencies section as follows before clicking on the Sync Now link at the top of the editor panel:

dependencies {
.
.
    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.1"
.
.
}Code language: Kotlin (kotlin)

Designing the User Interface Layout

Locate the res -> layout -> fragment_main.xml file, load it into the layout editor, and delete the default TextView component. From the Containers section of the widget palette drag and drop a RecyclerView onto the center of the layout canvas. Add constraints so that the view fills the entire canvas and each side is attached to the corresponding side of the parent container. With the RecyclerView selected, refer to the Attributes tool window and change the id to recyclerView if it does not already have this id.

Adding the List Row Layout

We now need to add a layout resource file containing a TextView to be used for each row in the list. Add this file now by right-clicking on the app -> res -> layout entry in the Project tool window and selecting the New -> Layout resource file menu option. Name the file list_row and change the root element to LinearLayout before clicking on OK to create the file and load it into the layout editor. With the layout editor in Design mode, drag a TextView object from the palette onto the layout where it will appear by default at the top of the layout:

Figure 70-1

With the TextView selected in the layout, use the Attributes tool window to set the id of the view to itemText, the layout_height to 50dp, and the textSize attribute to 20sp. With the text view still selected, unfold the gravity settings and set center to true and all other values to false:

Figure 70-2

Select the LinearLayout entry in the Component Tree window and set the layout_height attribute to wrap_ content.

Adding the RecyclerView Adapter

Add the RecyclerView adapter class to the project by right-clicking on the app -> java -> com -> ebookfrenzy -> sharedflowdemo -> ui.main entry in the Project tool window and selecting the New -> Kotlin File/Class… menu. In the dialog, name the class ListAdapter and choose Class from the list before pressing the keyboard Return key. With the resulting ListAdapter.kt class file loaded into the editor, implement the class as follows:

package com.ebookfrenzy.sharedflowdemo.ui.main
 
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.annotation.NonNull
import androidx.recyclerview.widget.RecyclerView
import com.ebookfrenzy.sharedflowdemo.R
 
class ListAdapter(private var itemsList: List) :
    RecyclerView.Adapter() {
    
    class MyViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        var itemText: TextView = view.findViewById(R.id.itemText)
    }
    
    @NonNull
    override fun onCreateViewHolder(parent: ViewGroup, 
                                  viewType: Int): MyViewHolder {
        val itemView = LayoutInflater.from(parent.context)
            .inflate(R.layout.list_row, parent, false)
        return MyViewHolder(itemView)
    }
  
    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        val item = itemsList[position]
        holder.itemText.text = item
    }
  
    override fun getItemCount(): Int {
        return itemsList.size
    }
}Code language: Kotlin (kotlin)

Completing the ViewModel

The next step is to add some code to the view model to create and start the SharedFlow instance. Begin by editing the MainViewModel.kt file so that it reads as follows:

package com.ebookfrenzy.sharedflowdemo.ui.main
 
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
 
class MainViewModel : ViewModel() {
 
    init {
        sharedFlowInit()
    }
 
    fun sharedFlowInit() {
    }
}Code language: Kotlin (kotlin)

When the ViewModel instance is created, the initializer will call the sharedFlowInit() function. The purpose of this function is to launch a new coroutine containing a loop in which new values are emitted using a shared flow. Before adding this code, we first need to declare the flow as follows:

.
.
class MainViewModel : ViewModel() {
 
    private val _sharedFlow = MutableSharedFlow()
    val sharedFlow = _sharedFlow.asSharedFlow()
.
.Code language: Kotlin (kotlin)

With the flow declared, code can now be added to the sharedFlowInit() function to launch the flow using the view model’s own scope. This will ensure that the flow ends when the view model is destroyed:

fun sharedFlowInit() {
    viewModelScope.launch {
        for (i in 1..1000) {
            delay(2000)
            _sharedFlow.emit(i)
        }
    }
}Code language: Kotlin (kotlin)

Modifying the Main Fragment for View Binding

Before we start to work on collecting values from the flow, we first need to make some changes to the MainFragment.kt file to add support for view binding. Open the file and modify it so that it reads as follows:

.
.
import com.ebookfrenzy.sharedflowdemo.databinding.FragmentMainBinding
 
class MainFragment : Fragment() {
 
    companion object {
        fun newInstance() = MainFragment()
    }
 
    private lateinit var viewModel: MainViewModel
    private var _binding: FragmentMainBinding? = null
    private val binding get() = _binding!!
 
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return inflater.inflate(R.layout.fragment_main, container, false)
        _binding = FragmentMainBinding.inflate(inflater, container, false)
        return binding.root
    }
 
    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
.
.Code language: Kotlin (kotlin)

Collecting the Flow Values

Before testing the app for the first time we need to add some code to perform the flow collection and display those values in the RecyclerView list. The intention is for collection to start automatically when the app launches so this code will be placed in the onActvityCreated() method of the MainFragment.kt file.

Start by adding some variables to store a reference to our list adapter and the array of items to be displayed in the RecyclerView. Now is also a good time to add the imports we will need to complete the app:

.
.
import androidx.lifecycle.Lifecycle
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
.
.
class MainFragment : Fragment() {
 
    companion object {
        fun newInstance() = MainFragment()
    }
 
    private lateinit var viewModel: MainViewModel
    private var _binding: FragmentMainBinding? = null
    private val binding get() = _binding!!
    private val itemList = ArrayList()
    private lateinit var listAdapter: ListAdapter
.
.Code language: Kotlin (kotlin)

Within the onActvityCreated() method, add some code to create a list adapter instance and assign it to the RecyclerView. We also need to configure the RecyclerView to use a LinearLayout manager:

override fun onActivityCreated(savedInstanceState: Bundle?) {
    super.onActivityCreated(savedInstanceState)
    viewModel = ViewModelProvider(this).get(MainViewModel::class.java)
 
    listAdapter = ListAdapter(itemList)
    val layoutManager = LinearLayoutManager(context)
    binding.recyclerView.layoutManager = layoutManager
    binding.recyclerView.adapter = listAdapter
}Code language: Kotlin (kotlin)

With these changes made we are ready to collect the values emitted by the shared flow and add them to the RecyclerView. Add code to the onActivityCreated() method so that it now reads as follows:

override fun onActivityCreated(savedInstanceState: Bundle?) {
.
.
    lifecycleScope.launch {
        viewModel.sharedFlow.collect() { value ->
            itemList.add(value.toString())
            listAdapter.notifyItemInserted(itemList.lastIndex)
            binding.recyclerView.smoothScrollToPosition(listAdapter.itemCount)
         }
    }
}Code language: Kotlin (kotlin)

This code accesses the shared flow instance within the view model and begins collecting values from the stream. Each collected value is added to the itemList array that was previously used when the ListAdapter was initialized. We then notify the adapter that a new item has been added to the end of the list. This will cause the RecyclerView to update so that the new value appears in the list. We have also added code to instruct the RecyclerView to scroll smoothly to the last position in the list so that the most recent values are automatically visible.

Testing the SharedFlowDemo App

Compile and run the app on a device or emulator and verify that values appear within the RecyclerView list as they are emitted by the shared flow. Rotate the device into landscape orientation to trigger a configuration change and confirm that the sequence of values continues without restarting from zero:

Figure 70-3

With the app now working, it is time to look at what happens when it is placed in the background.

Handling Flows in the Background

In our app, we have a shared flow that feeds values to the user interface in the form of a RecyclerView. By performing the collection in a coroutine scope, the user interface remains responsive while the flow is being collected (you can verify this by scrolling up and down within the list of values while the list is updating). This raises the question of what happens when the app is placed in the background. To find out, we can add some diagnostic output to both the emitter and collector code. First, edit the MainViewModel.kt file and add a println() call within the body of the emission for loop:

fun sharedFlowInit() {
    viewModelScope.launch {
        for (i in 1..1000) {
            delay(1100)
            println("Emitting $i")
            _sharedFlow.emit(i)
        }

    }
}Code language: Kotlin (kotlin)

Make a similar change to the collection code block in the MainFragment.kt file as follows:

.
.
    lifecycleScope.launch {
        viewModel.sharedFlow.collect() { value ->
            println("Collecting $value")
            itemList.add(value.toString())
            listAdapter.notifyDataSetChanged()
            binding.recyclerView.smoothScrollToPosition(listAdapter.itemCount)
         }
    }
.
.Code language: Kotlin (kotlin)

Once these changes have been made, display the Logcat tool window, enter System.out into the search bar, and run the app. As the list of values updates, output similar to the following should appear in the Logcat panel:

Emitting1
Collecting 1
Emitting 2
Collecting 2
Emitting 3
Collecting 3
.
.Code language: plaintext (plaintext)

Now place the app in the background and note that both the emission and collection operations continue to run, even though the app is no longer visible to the user. The continued emission is to be expected and is the correct behavior for a shared flow residing within a view model. It is wasteful of resources, however, to be collecting data and updating a user interface that is not currently visible to the user. We can resolve this problem by executing the collection using the repeatOnLifecycle function.

The repeatOnLifecycle function is a suspend function that runs a specified block of code each time the current lifecycle reaches or exceeds one of the following states (a topic covered previously in the “Working with Android Lifecycle-Aware Components” chapter):

  • Lifecycle.State.INITIALIZED
  • Lifecycle.State.CREATED
  • Lifecycle.State.STARTED
  • Lifecycle.State.RESUMED
  • Lifecycle.State.DESTROYED

Conversely, when the lifecycle drops below the target state, the coroutine is canceled.

In this case, we want the collection to start each time Lifecycle.State.START is reached and to stop when the lifecycle is suspended. To implement this, modify the collection code as follows:

lifecycleScope.launch {
   repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.sharedFlow.collect() { value ->
            println("Collecting $value")
            itemList.add(value.toString())
            listAdapter.notifyDataSetChanged()
            binding.recyclerView.smoothScrollToPosition(listAdapter.itemCount)
        }
   }
}Code language: Kotlin (kotlin)

Run the app once again, place it in the background and note that only the emission diagnostic messages appear in the Logcat output confirming that the main fragment is no longer collecting values and adding them to the RecyclerView list. When the app is brought to the foreground, the collection will resume at the latest emitted value since replay was not configured on the shared flow.

Summary

In this chapter we created a SharedFlow instance within a view model. We then collected the streamed values within the main fragment and used that data to update the user interface. We also outlined the importance of avoiding unnecessary flow-driven user interface updates when an app is placed in the background, a problem that can easily be resolved using the repeatOnLifecycle function. This function can be used to cancel and restart asynchronous tasks such as flow collection when a target lifecycle state is reached by the containing lifecycle.

Kotlin Flow Code Examples

The earlier chapter titled “An Introduction to Kotlin Coroutines” taught us about Kotlin Coroutines and explained how they can be used to perform multiple tasks concurrently without blocking the main thread. As we have seen, coroutine suspend functions are ideal for performing tasks that return a single result value. In this chapter, we will introduce Kotlin Flows and explore how these can be used to return sequential streams of results from coroutine-based tasks.

By the end of the chapter, you should have a good understanding of the Flow, StateFlow, and SharedFlow Kotlin types, and appreciate the difference between hot and cold flow streams. In the next chapter (An Android SharedFlow Tutorial), we will look more closely at using SharedFlow within the context of Android app development

Understanding Kotlin Flows

Flows are a part of the Kotlin programming language and are designed to allow multiple values to be returned sequentially from coroutine-based asynchronous tasks. A stream of data arriving over time via a network connection would, for example, be an ideal situation for using a Kotlin flow.

Flows are comprised of producers, intermediaries, and consumers. Producers are responsible for providing the data that makes up the flow. The code that retrieves the stream of data from our hypothetical network connection, for example, would be considered a producer. As each data value becomes available, the producer emits that value to the flow. The consumer sits at the opposite end of the flow stream and collects the values as they are emitted by the producer.

Intermediaries may be placed between the producer and consumer to perform additional operations on the data such as filtering the stream, performing additional processing, or transforming the data in other ways before it reaches the consumer. Figure 69-1 illustrates the typical structure of a Kotlin flow:

Figure 69-1

The flow shown in the above diagram consists of a single producer and consumer. In practice, it is possible both for multiple consumers to collect emissions from a single producer, and for a single consumer to collect data from multiple producers. The remainder of this chapter will demonstrate many of the key features of Kotlin flows.

Creating the Sample Kotlin Flow Project

Select the New Project option from the Android Studio welcome screen and, within the resulting new project dialog, choose the Empty Activity template before clicking on the Next button.

Enter FlowDemo into the Name field and specify com.ebookfrenzy.flowdemo as the package name. Before clicking on the Finish button, change the Minimum API level setting to API 26: Android 8.0 (Oreo) and the Language menu to Kotlin.

Once the new project has been created, locate and load the activity_main.xml layout file located in the Project tool window under app -> res -> layout and, with the Layout Editor tool in Design mode, replace the TextView object with a Button view and set the text property so that it reads “Start”. Once the text value has been set, follow the usual steps to extract the string to a resource.

With the button still selected in the layout, locate the onClick property in the Attributes panel and configure it to call a method named handleFlow.

Adding the Kotlin Lifecycle Library

Kotlin flow requires that the Kotlin extensions lifecycle library is included as a dependency, so edit the build.gradle (Module: FlowDemo.app) file and add the library to the dependencies section as follows:

dependencies {
.
.
    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.1"
.
.
}Code language: Gradle (gradle)

When prompted, click on the Sync Now button at the top of the editor panel to commit to the change.

Declaring a Flow

The most basic form of flow is represented by the Kotlin Flow type. Each flow is only able to emit data of a single type which must be specified when the flow is declared. The following declaration, for example, declares a Flow instance designed to stream String-based data:

Flow<String>Code language: Kotlin (kotlin)

When declaring a flow, we need to assign to it the code that will generate the data stream. This code is referred to as the producer block. This can be achieved using the flow builder which takes as a parameter a coroutine suspend block containing the producer block code. Add the following code to the MainActivity.kt file to declare a flow named myFlow designed to emit a stream of integer values:

.
.
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
.
.
fun myFlow(): Flow = flow {
    // Producer block  
}Code language: Kotlin (kotlin)

As an alternative to the flow builder, the flowOf() builder can be used to convert a fixed set of values into a flow:

val myFlow2 = flowOf(2, 4, 6, 8)Code language: Kotlin (kotlin)

Also, many Kotlin collection types now include an asFlow() extension function that can be called to convert the contained data to a flow. The following code, for example, converts an array of string values to a flow:

val myArrayFlow = arrayOf("Red", "Green", "Blue").asFlow()Code language: Kotlin (kotlin)

Emitting Flow Data

Once a flow has been built, the next step is to make sure the data is emitted so that it reaches any consumers that are observing the flow. Of the three flow builders we looked at in the previous section, only the flowOf() and asFlow() builders create flows that automatically emit the data as soon as a consumer starts collecting. In the case of the flow builder, however, we need to write code to manually emit each value as it becomes available. We achieve this by making calls to the emit() function and passing through as an argument the current value to be streamed. The following changes to our myFlow declaration implement a loop that emits the value of an incrementing counter. To demonstrate the asynchronous nature of flow streams, a three-second delay is performed on each loop iteration:

fun myFlow(): Flow = flow {
    var counter = 1
    
    while (counter < 6) {
        emit(counter)
        counter++
        delay(2000)
    }
}Code language: Kotlin (kotlin)

Collecting Flow Data

The streaming data within a flow can be collected by a consumer by calling the collect() method on the flow instance. This will continue to collect data from the stream either until the stream ends or the lifecycle scope in which the collection is being performed is destroyed. For example, we can collect the data from the myFlow stream and output each value by adding the handleFlow() onClick function:

.
.
import android.view.View
.
.
fun handleFlow(view: View) {
    lifecycleScope.launch {
        myFlow().collect() { value ->
            println("Collected value = $value")
        }
    }
}Code language: Kotlin (kotlin)

Note that collect() is a suspend function so must be called from within a coroutine scope.

Compile and run the app on a device or emulator, display the Logcat tool window and enter System.out into the search bar as shown in Figure 69-2. This will filter the log output to display only that generated by the println() statement:

Figure 69-2

When the Start button is clicked in the running app, the following output should appear with a two-second delay between each output:

Collected value = 1
Collected value = 2
Collected value = 3
Collected value = 4
Collected value = 5Code language: plaintext (plaintext)

To add code to be executed when the stream ends, the collection can be performed in a try/finally construct, for example:

fun handleFlow(view: View) {
    lifecycleScope.launch {
        try {
            myFlow().collect() { value ->
                println("Collected value = $value")
            }
        } finally {
            println("Flow stream ended.")
        }
    }
}Code language: Kotlin (kotlin)

The collect() operator will collect every value emitted by the producer, even if new values are emitted while the last value is still being processed in the consumer. For example, our producer is configured to emit a new value every two seconds. Suppose, however, that we simulate our consumer taking 2.5 seconds to process each collected value as follows:

fun handleFlow(view: View) {
    lifecycleScope.launch {
        myFlow().collect() { value ->
            println("Collected value = $value")
            delay(2500)
        }
    }
}Code language: Kotlin (kotlin)

When executed, we will still see all of the values listed in the output because collect() does not discard any uncollected values regardless of whether more recent values have been emitted since the last collection. This type of behavior is essential to avoid data loss within the flow. In some situations, however, the consumer may be uninterested in any intermediate values emitted between the most recently processed value and the latest emitted value. In this case, the collectLatest() operator can be called on the flow instance. This operator works by canceling the current collection if a new value arrives before processing completes on the previous value and restarts the process on the latest value.

The conflate() operator is similar to the collectLatest() operator except that instead of canceling the current collection operation when a new value arrives, conflate() allows the current operation to complete, but discards intermediate values that arrive during this process. When the current operation completes, the most recent value is then collected.

Another collection operator is the single() operator. This operator collects a single value from the flow and throws an exception if it finds another value in the stream. This operator is generally only useful where the appearance of a second stream value indicates that something else has gone wrong somewhere in the app or data source.

Adding a Flow Buffer

When a consumer takes time to process the values emitted by a producer, there is the potential for execution time inefficiencies to occur. Suppose, for example, that in addition to the two-second delay between each emission from our myFlow producer, the collection process in our consumer takes an additional second to complete. We can simulate this behavior as follows:

.
.
import kotlin.system.measureTimeMillis
.
.
fun handleFlow(view: View) {
    lifecycleScope.launch {
        val elapsedTime = measureTimeMillis {
            myFlow()
                .collect() { value ->
                    println("Collected value = $value")
                    delay(1000)
                }
        }
        println("Duration = $elapsedTime")
    }
}Code language: Kotlin (kotlin)

To allow us to measure the total time to fully process the flow, the consumer code has been placed in the closure of a call to the Kotlin measureTimeMillis() function. After execution completes, a duration similar to the following will be reported:

Duration = 15024Code language: plaintext (plaintext)

This accounts for approximately ten seconds to process the five values within myFlow and an additional five seconds for those values to be collected. There is an inefficiency here because the producer is waiting for the consumer to process each value before starting on the next value. This would be much more efficient if the producer did not have to wait for the consumer. We could, of course, use the collectLatest() or conflate() operators, but only if the loss of intermediate values is not a concern. To speed up the processing while also collecting every emitted value we can make use of the buffer() operator. This operator buffers values as they are emitted and passes them to the consumer when it is ready to receive them. This allows the producer to continue emitting values while the consumer is processing preceding values while also ensuring that every emitted value is collected. The buffer() operator may be applied to a flow as follows:

val elapsedTime = measureTimeMillis {
    myFlow()
        .buffer()
        .collect() { value ->
        println("Collected value = $value")
        delay(1000)
    }
}
println("Duration = $elapsedTime")Code language: Kotlin (kotlin)

Execution of the above code indicates that we have now reclaimed the five seconds previously lost in the collection code:

Duration = 10323Code language: plaintext (plaintext)

Transforming Data with Intermediaries

All of the examples we have looked at so far in this chapter have passed the data values to the consumer without any modifications. Changes to the data can be made between the producer and consumer by applying one or more intermediate flow operators. In this section, we will look at some of these operators.

The map() operator can be used to convert the value to some other value. We can use map(), for example, to convert our integer value to a string:

fun handleFlow(view: View) {
    lifecycleScope.launch {
        myFlow()
            .map {
                "Collected value = $it"
            }
            .collect() {
                println(it)
            }
        }
    }
}Code language: Kotlin (kotlin)

When executed, this will give us the following output:

Collected value = 1
Collected value = 2
Collected value = 3
Collected value = 4
Collected value = 5Code language: plaintext (plaintext)

The map() operator will perform the conversion on every collected value. The filter() operator can be used to control which values get collected. The filter code block needs to contain an expression that returns a Boolean value. Only if the expression evaluates to true does the value pass through to the collection. The following code filters odd numbers out of the data flow (note that we’ve left the map() operator in place to demonstrate the chaining of operators):

fun handleFlow(view: View) {
    lifecycleScope.launch {
        myFlow()
            .filter {
                it % 2 == 0
            }
            .map {
                "Collected value $it"
            }
            .collect() {
                println(it)
            }
        }
    }
}Code language: Kotlin (kotlin)

The above changes will generate the following output:

Collected value = 2
Collected value = 4Code language: plaintext (plaintext)

The transform() operator serves a similar purpose to map() but provides more flexibility. The transform() operator also needs to manually emit the modified result. A particular advantage of transform() is that it can emit multiple values as demonstrated below:

.
.
myFlow()
    .transform {
        emit("Value = $it")
        var doubled = it * 2
        emit("Value doubled = $doubled")
    }
    .collect {
        println(it)
    }
}
.
.
// Output
Value = 1
Value doubled = 2
Value = 2
Value doubled = 4
Value = 3
Value doubled = 6
Value = 4
Value doubled = 8
Value = 5
Value doubled = 10Code language: Kotlin (kotlin)

Terminal Flow Operators

All of the collection operators covered previously are referred to as terminal flow operators. The reduce() operator is one of several other terminal flow operators that can be used in place of a collection operator to make changes to the flow data. The reduce() operator takes two parameters in the form of an accumulator and a value. The first flow value is placed in the accumulator and a specified operation is performed between the accumulator and the current value (with the result stored in the accumulator):

.
.
myFlow()
    .reduce { accumulator, value ->
        println("accumulator = $accumulator, value = $value")
        accumulator + value
    }
}
.
.
// Output
accumulator = 1, value = 2
accumulator = 3, value = 3
accumulator = 6, value = 4
accumulator = 10, value = 5Code language: Kotlin (kotlin)

The fold() operator works similarly to the reduce() operator, with the exception that it is passed an initial accumulator value:

.
.
myFlow()
    .fold(10) { accumulator, value ->
        println("accumulator = $accumulator, value = $value")
        accumulator * value
    }
}
.
.
// Output
accumulator = 10, value = 1
accumulator = 10, value = 2
accumulator = 20, value = 3
accumulator = 60, value = 4
accumulator = 240, value = 5Code language: Kotlin (kotlin)

Flow Flattening

As we have seen in earlier examples, we can use operators to perform tasks on values collected from a flow. An interesting situation occurs, however, when that task itself creates one or more flows resulting in a “flow of flows”. In such situations, these streams can be flattened into a single stream. Consider the following example code which declares two flows:

fun myFlow(): Flow = flow {
    for (i in 1..5) {
        emit(i)
    }
}
 
fun doubleIt(value: Int) = flow {
    emit(value)
    delay(1000)
    emit(value + value)
}Code language: Kotlin (kotlin)

If we were to call doubleIt() for each value in the myFlow stream we would end up with a separate flow for each value. This problem can be solved by concatenating the doubleIt() streams into a single flow using the flatMapConcat() operator as follows:

.
.
myFlow()
    .flatMapConcat { doubleIt(it) }
    .collect { println(it) }
.
.Code language: Kotlin (kotlin)

When this modified code executes we will see the following output from the collect() operator:

1
2
2
4
3
6
4
8
5
10Code language: plaintext (plaintext)

As we can see from the output, the doubleIt() flow has emitted the value provided by myFlow followed by the doubled value. When using the flatMapConcat() operator, the doubleIt() calls are being performed synchronously, causing execution to wait until doubleIt() has emitted both values before processing the next flow value. The emitted values can instead be collected asynchronously using the flatMapMerge() operator as follows:

myFlow()
    .flatMapMerge { doubleIt(it) }
    .collect { println(it) }
}Code language: Kotlin (kotlin)

When executed, the following output will appear:

1
2
3
4
5
2
4
6
8
10Code language: plaintext (plaintext)

Combining Multiple Flows

Multiple flows can be combined into a single flow using the zip() and combine() operators. The following code demonstrates the zip() operator being used to convert two flows into a single flow:

fun handleFlow(view: View) {
    lifecycleScope.launch {
        val flow1 = (1..5).asFlow()
            .onEach { delay(1000) }
        val flow2 = flowOf("one", "two", "three", "four")
            .onEach { delay(1500) }
        flow1.zip(flow2) { value, string -> "$value, $string" }
            .collect { println(it) }
    }
}
// Output
1, one
2, two
3, three
4, fourCode language: Kotlin (kotlin)

Note that we have applied the onEach() operator to both flows in the above code. This is a useful operator for performing a task on receipt of each stream value.

The zip() operator will wait until both flows have emitted a new value before performing the collection. The combine() operator works slightly differently in that it proceeds as soon as either flow emits a new value, using the last value emitted by the other flow in the absence of a new value:

.
.
val flow1 = (1..5).asFlow()
    .onEach { delay(1000) }
val flow2 = flowOf("one", "two", "three", "four")
    .onEach { delay(1500) }
flow1.combine(flow2) { value, string -> "$value, $string" }
    .collect { println(it) }
.
.
// Output
1, one
2, one
3, one
3, two
4, two
4, three
5, three
5, fourCode language: Kotlin (kotlin)

As we can see from the output, multiple instances have occurred where the last value has been reused on a flow because a new value was emitted on the other.

Hot and Cold Flows

So far in this chapter, we have looked exclusively at the Kotlin Flow type. Kotlin also provides additional types in the form of StateFlow and SharedFlow. Before exploring these, however, it is important to understand the concept of hot and cold flows.

A stream declared using the Flow type is referred to as a cold flow because the code within the producer does not begin executing until a consumer begins collecting values. StateFlow and SharedFlow, on the other hand, are referred to as hot flows because they begin emitting values immediately, regardless of whether any consumers are collecting the values.

Once a consumer begins collecting from a hot flow, it will receive the latest value emitted by the producer followed by any subsequent values. Unless steps are taken to implement caching, any previous values emitted before the collection starts will be lost.

Another important difference between Flow, StateFlow, and SharedFlow is that a Flow-based stream cannot have multiple collectors. Each Flow collector launches a new flow with its own independent data stream. With StateFlow and SharedFlow, on the other hand, multiple collectors share access to the same flow.

StateFlow

StateFlow, as the name suggests, is primarily used as a way to observe a change in state within an app such as the current setting of a counter, toggle button, or slider. Each StateFlow instance is used to store a single value that is likely to change over time and to notify all consumers when those changes occur. This enables you to write code that reacts to changes in state instead of code that has to continually check whether or not a state value has changed. StateFlow behaves the same way as LiveData with the exception that LiveData has lifecycle awareness and does not require an initial value (LiveData was covered previously beginning with the chapter titled “Modern Android App Architecture with Jetpack”).

To create a StateFlow stream, begin by creating an instance of MutableStateFlow, passing through a mandatory initial value. This is the variable that will be used to change the current state value from within the app code:

private val _stateFlow = MutableStateFlow(0)Code language: Kotlin (kotlin)

Next, call asStateFlow() on the MutableStateFlow instance to convert it into a StateFlow from which changes in state can be collected:

val stateFlow = _stateFlow.asStateFlow()Code language: Kotlin (kotlin)

Once created, any changes to the state are made via the value property of the mutable state instance. The following code, for example, increments the state value:

_stateFlow.value += 1Code language: Kotlin (kotlin)

Once the flow is active, the state can be consumed in the usual ways, though it is generally recommended to collect from StateFlow using the collectLatest() operator, for example:

stateFlow.collectLatest { println("Counter = $it") }Code language: Kotlin (kotlin)

To try out this example, make the following modifications to the MainActivity.kt file:

.
.
class MainActivity : AppCompatActivity() {
 
    private val _stateFlow = MutableStateFlow(0)
    val stateFlow = _stateFlow.asStateFlow()
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
 
        lifecycleScope.launch {
            stateFlow.collectLatest {
                println("Counter = $it")
            }
        }
    }
 
    fun handleFlow(view: View) {
        _stateFlow.value += 1
    }
}Code language: Kotlin (kotlin)

Run the app and verify that the Start button outputs the incremented counter value each time it is clicked.

SharedFlow

SharedFlow provides a more general-purpose streaming option than that offered by StateFlow. Some of the key differences between StateFlow and SharedFlow are as follows:

  • Consumers are generally referred to as subscribers.
  • An initial value is not provided when creating a SharedFlow instance.
  • SharedFlow allows values that were emitted prior to collection starting to be “replayed” to the collector.
  • SharedFlow emits values instead of using a value property.

SharedFlow instances are created using MutableSharedFlow as the backing property on which we call the asSharedFlow() to obtain a SharedFlow reference:

.
.
import kotlinx.coroutines.channels.BufferOverflow
.
.
class MainActivity : AppCompatActivity() {
 
private val _sharedFlow = MutableSharedFlow<int>(
        replay = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val sharedFlow = _sharedFlow.asSharedFlow()
.
.</int>Code language: Kotlin (kotlin)

As configured above, new flow subscribers will receive the last 10 values before receiving any new values. The above flow is also configured to discard the oldest value when more than 10 values are buffered. The full set of options for handling buffer overflows are as follows:

  • DROP_LATEST – The latest value is dropped when the buffer is full leaving the buffer unchanged as new values are processed.
  • DROP_OLDEST – Treats the buffer as a “last-in, first-out” stack where the oldest value is dropped to make room for a new value when the buffer is full.
  • SUSPEND – The flow is suspended when the buffer is full.

Values are emitted on a SharedFlow stream by calling the emit() method of the MutableSharedFlow instance:

fun handleFlow(view: View) {
 
    var counter = 1
 
    lifecycleScope.launch {
        while (counter < 6) {
            _sharedFlow.emit(counter)
            counter++
            delay(2000)
        }
    }
}Code language: Kotlin (kotlin)

Once the flow is active, subscribers can collect values using the usual techniques on the SharedFlow instance. For example, we can add the following collection code to the onCreate() method of our example project to output the flow values:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
 
    lifecycleScope.launch {
        sharedFlow.collect {
            println("$it")
        }
    }
}Code language: Kotlin (kotlin)

Also, the current number of subscribers to a SharedFlow stream can be obtained via the subscriptionCount property of the mutable instance:

val subCount = _sharedFlow.subscriptionCountCode language: Kotlin (kotlin)

Summary

Kotlin flows allow sequential data or state changes to be returned over time from asynchronous tasks. A flow consists of a producer that emits a sequence of sequential values and consumers that collect and process those values. The flow stream can be manipulated between the producer and consumer by applying one or more intermediary operators including transformations and filtering. Flows are created based on the Flow, StateFlow, and SharedFlow types. A Flow-based stream can only have a single collector while StateFlow and SharedFlow can have multiple collectors.

Flows are categorized as being hot or cold. A cold flow does not begin emitting values until a consumer begins collection. Hot flows, on the other hand, begin emitting values as soon as they are created, regardless of whether or not the values are being collected. In the case of SharedFlow, a predefined number of values may be buffered and subsequently replayed to new subscribers when they begin collecting values.

An Android Kotlin Coroutines Tutorial

The previous chapter introduced the key concepts of performing asynchronous tasks within Android apps using Kotlin coroutines. This chapter will build on this knowledge to create an example app that launches thousands of coroutines at the touch of a button.

Creating the Coroutine Example Application

Select the New Project option from the welcome screen and, within the resulting new project dialog, choose the Empty Activity template before clicking on the Next button.

Enter CoroutineDemo into the Name field and specify com.ebookfrenzy.coroutinedemo as the package name. Before clicking on the Finish button, change the Minimum API level setting to API 26: Android 8.0 (Oreo) and the Language menu to Kotlin. Migrate the project to view binding using the steps outlined in section “Migrating a Project to View Binding”.

Adding Coroutine Support to the Project

The current version of Android Studio does not automatically include support for coroutines in newly created projects. Before proceeding, therefore, edit the Gradle Scripts -> build.gradle (Module: CoroutineDemo.app) file and add the following lines to the dependencies section (noting, as always, that newer versions of the libraries may be available):

dependencies {
.
.
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.1'
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'
.
.
}Code language: Gradle (gradle)

After making the change, click on the Sync Now link at the top of the editor panel to commit the changes.

Designing the User Interface

The user interface will consist of a button to launch coroutines together with a Seekbar to specify how many coroutines are to be launched asynchronously each time the button is clicked. As the coroutines execute, a TextView will update when individual coroutines start and end.

Begin by loading the activity_main.xml layout file and add the Button, TextView, and SeekBar objects so that the layout resembles that shown in Figure 64-1:

Figure 64-1

To implement the layout constraints shown above begin by clearing all constraints on the layout using the toolbar button. Shift-click on the four objects so that all are selected, right-click over the top-most TextView and select the Center -> Horizontally menu option. Right-click once again, this time selecting the Chains -> Create Vertical Chain option.

Select the SeekBar and change the layout_width property to 0dp (match_constraints) before adding a 24dp margin on the left and right-hand sides as shown in Figure 64-2:

Figure 64-2

Modify the onClick attribute for the Button to call a method named launchCoroutines and change the ids of the top-most TextView, the SeekBar and the lower TextView to countText, seekBar, and statusText respectively. Finally, change the text on the Button to read “Launch Coroutines” and extract the text to a string resource.

Implementing the SeekBar

The SeekBar controls the number of asynchronous coroutines, ranging from 1 to 2000, that are launched each time the button is clicked. Remaining within the activity_main.xml file, select the SeekBar and use the Attributes tool window to change the max property to 2000. Next, edit the MainActivity.kt file, add a variable in which to store the current slider setting and modify the onCreate() method to add a SeekBar listener:

.
.
import android.widget.SeekBar
.
. 
class MainActivity : AppCompatActivity() {
 
    private lateinit var binding: ActivityMainBinding
    private var count: Int = 1
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
 
        binding.seekBar.setOnSeekBarChangeListener(object :
            SeekBar.OnSeekBarChangeListener {
            override fun onProgressChanged(seek: SeekBar,
                                           progress: Int, fromUser: Boolean) {
                count = progress
                binding.countText.text = "${count} coroutines"
            }
 
            override fun onStartTrackingTouch(seek: SeekBar) {
            }
 
            override fun onStopTrackingTouch(seek: SeekBar) {
            }
        })
    }
.
.Code language: Kotlin (kotlin)

When the seekbar slides, the current value will be stored in the count variable and displayed on the countText view.

Adding the Suspend Function

When the user taps the button the app will need to launch the number of coroutines selected in the SeekBar. The launchCoroutines() onClick method will achieve this using the coroutine launch builder to execute a suspend function. Since the suspend function will return a status string to be displayed on the statusText TextView object, it will need to be implemented using the async builder. All of these actions will need to be performed within a coroutine scope which also needs to be declared. Within the MainActivity.kt file make the following changes:

.
.
import kotlinx.coroutines.*
.
.
class MainActivity : AppCompatActivity() {

    private val coroutineScope = CoroutineScope(Dispatchers.Main)
.
.
    suspend fun performTask(tasknumber: Int): Deferred<String> =
        coroutineScope.async(Dispatchers.Main) {
            delay(5_000)
            return@async "Finished Coroutine ${tasknumber}"
        }
.
.
}Code language: Kotlin (kotlin)

Given that the function only performs a small task and involves changes to the user interface, the coroutine is executed using the Main dispatcher. It is passed the sequence number of the coroutine to be launched, delays for 5 seconds, and then returns a string indicating that the numbered coroutine has finished.

Implementing the launchCoroutines Method

The final task before testing the app is to add the launchCoroutines() method which is called when the Button object is clicked. This method should be added to the MainActivity.kt file as follows:

.
.
import android.view.View
.
.
    fun launchCoroutines(view: View) {
 
        (1..count).forEach {
            binding.statusText.text = "Started Coroutine ${it}"
            coroutineScope.launch(Dispatchers.Main) {
                binding.statusText.text = performTask(it).await()
            }
        }
    }
.
.Code language: Kotlin (kotlin)

The method implements a loop to launch the requested number of coroutines and updates the status TextView each time a result is returned from a completed coroutine via an await() method call.

Testing the App

Build and run the app on a device or emulator and move the SeekBar to a low number (for example 10) before tapping the launch button. The status text will update each time a coroutine is launched until the maximum is reached. After each coroutine completes the 5-second delay the status text will update until all 10 have completed (in practice these status updates will happen so quickly that it will be difficult to see the status changes).

Repeat the process with the SeekBar set to 2000, this time sliding the seekbar back and forth as the coroutines run to verify that the main thread is still running and has not been blocked.

Finally, with the Logcat panel displayed, set the SeekBar to 2000 and repeatedly click on the launch button. After about 15 clicks the Logcat panel will begin displaying messages similar to the following:

I/Choreographer: Skipped 52 frames!  The application may be doing too much work on its main thread.Code language: plaintext (plaintext)

Although the app continues to function, clearly the volume of coroutines running within the app is beginning to overload the main thread. The fact that this only occurs when tens of thousands of coroutines are executing concurrently is a testament to the efficiency of Kotlin coroutines. When this message begins to appear in your own apps, however, it may be a sign either that too many coroutines are running, or the asynchronous workload being performed is too heavy for the main thread. That being the case, a different dispatcher may need to be used, perhaps by using the withContext builder.

Summary

Building on the information covered in An Introduction to Kotlin Coroutines, this chapter has stepped through the creation of an example app that demonstrates the use of Kotlin coroutines within an Android app. The example demonstrated the use of the Main dispatcher to launch thousands of asynchronous coroutines, including returning results.

An Introduction to Kotlin Coroutines

The previous chapter introduced the concepts of threading on Android and explained how the user interface of an app runs on the main thread. To avoid degrading or interrupting user interface responsiveness, it is important that time-consuming tasks not block the execution of the main thread. One option, as outlined in the previous chapter, is to run any such tasks on a background thread, thereby leaving the main thread to continue managing the user interface. This can be achieved either directly using thread handlers or by making use of the AsyncTask class.

Although AsyncTask and thread handlers provide a way to perform tasks on separate threads, it can be time-consuming to implement, and confusing to read and maintain the associated code in an app project. This approach is also not the most efficient solution when large numbers of threads are required by an app.

Fortunately, Kotlin provides a lightweight alternative in the form of Coroutines. In this chapter, we will introduce the basic concepts of Coroutines, including terminology such as dispatchers, coroutine scope, suspend functions, coroutine builders, and structured concurrency. The chapter will also introduce the basic concepts of channel-based communication between coroutines.

What are Coroutines?

Coroutines are blocks of code that execute asynchronously without blocking the thread from which they are launched. Coroutines can be implemented without having to worry about building complex AsyncTask implementations or directly managing multiple threads. Because of the way they are implemented, coroutines are much more efficient and less resource-intensive than using traditional multi-threading options. Coroutines also make for code that is much easier to write, understand and maintain since it allows code to be written sequentially without having to write callbacks to handle thread-related events and results.

Although a relatively recent addition to Kotlin, there is nothing new or innovative about coroutines. Coroutines in one form or another have existed in programming languages since the 1960s and are based on a model known as Communicating Sequential Processes (CSP). In fact, Kotlin still uses multi-threading behind the scenes, though it does so highly efficiently.

Threads vs Coroutines

A problem with threads is that they are a finite resource and expensive in terms of CPU capabilities and system overhead. In the background, a lot of work is involved in creating, scheduling, and destroying a thread. Although modern CPUs are able to run large numbers of threads, the actual number of threads that can be run in parallel at any one time is limited by the number of CPU cores (though newer CPUs have 8 cores, most Android devices contain CPUs with 4 cores). When more threads are required than there are CPU cores, the system has to perform thread scheduling to decide how the execution of these threads is to be shared between the available cores.

To avoid these overheads, instead of starting a new thread for each coroutine and then destroying it when the coroutine exits, Kotlin maintains a pool of active threads and manages how coroutines are assigned to those threads. When an active coroutine is suspended it is saved by the Kotlin runtime and another coroutine is resumed to take its place. When the coroutine is resumed, it is simply restored to an existing unoccupied thread within the pool to continue executing until it either completes or is suspended. Using this approach, a limited number of threads are used efficiently to execute asynchronous tasks with the potential to perform large numbers of concurrent tasks without the inherent performance degenerations that would occur using standard multithreading.

Coroutine Scope

All coroutines must run within a specific scope which allows them to be managed as groups instead of as individual coroutines. This is particularly important when canceling and cleaning up coroutines, for example when a Fragment or Activity is destroyed, and ensuring that coroutines do not “leak” (in other words continue running in the background when they are no longer needed by the app). By assigning coroutines to a scope they can, for example, all be canceled in bulk when they are no longer needed.

Kotlin and Android provide some built-in scopes as well as the option to create custom scopes using the CoroutineScope class. The built-in scopes can be summarized as follows:

  • GlobalScope – GlobalScope is used to launch top-level coroutines which are tied to the entire lifecycle of the application. Since this has the potential for coroutines in this scope to continue running when not needed (for example when an Activity exits) use of this scope is not recommended for use in Android applications. Coroutines running in GlobalScope are considered to be using unstructured concurrency.
  • ViewModelScope – Provided specifically for use in ViewModel instances when using the Jetpack architecture ViewModel component. Coroutines launched in this scope from within a ViewModel instance are automatically canceled by the Kotlin runtime system when the corresponding ViewModel instance is destroyed.
  • LifecycleScope – Every lifecycle owner has associated with it a LifecycleScope. This scope is canceled when the corresponding lifecycle owner is destroyed making it particularly useful for launching coroutines from within activities and fragments.

For all other requirements. A custom scope will most likely be used. The following code, for example, creates a custom scope named myCoroutineScope:

private val myCoroutineScope = CoroutineScope(Dispatchers.Main)Code language: Kotlin (kotlin)

The coroutineScope declares the dispatcher that will be used to run coroutines (though this can be overridden) and must be referenced each time a coroutine is started if it is to be included within the scope. All of the running coroutines in a scope can be canceled via a call to the cancel() method of the scope instance:

myCoroutineScope.cancel()Code language: Kotlin (kotlin)

Suspend Functions

A suspend function is a special type of Kotlin function that contains the code of a coroutine. It is declared using the Kotlin suspend keyword which indicates to Kotlin that the function can be paused and resumed later, allowing long-running computations to execute without blocking the main thread. The following is an example suspend function:

suspend fun mySlowTask() {
    // Perform long-running task here    
}Code language: Swift (swift)

Coroutine Dispatchers

Kotlin maintains threads for different types of asynchronous activity and, when launching a coroutine, it will be necessary to select the appropriate dispatcher from the following options:

  • Dispatchers.Main – Runs the coroutine on the main thread and is suitable for coroutines that need to make changes to the UI and as a general-purpose option for performing lightweight tasks.
  • Dispatchers.IO – Recommended for coroutines that perform network, disk, or database operations.
  • Dispatchers.Default – Intended for CPU-intensive tasks such as sorting data or performing complex calculations.

The dispatcher is responsible for assigning coroutines to appropriate threads and suspending and resuming the coroutine during its lifecycle. In addition to the predefined dispatchers, it is also possible to create dispatchers for your own custom thread pools.

Coroutine Builders

The coroutine builders bring together all of the components covered so far and actually launch the coroutines so that they start executing. For this purpose, Kotlin provides the following six builders:

  • launch – Starts a coroutine without blocking the current thread and does not return a result to the caller. Use this builder when calling a suspend function from within a traditional function, and when the results of the coroutine do not need to be handled (sometimes referred to as “fire and forget” coroutines).
  • async – Starts a coroutine and allows the caller to wait for a result using the await() function without blocking the current thread. Use async when you have multiple coroutines that need to run in parallel. The async builder can only be used from within another suspend function.
  • withContext – Allows a coroutine to be launched in a different context from that used by the parent coroutine. A coroutine running using the Main context could, for example, launch a child coroutine in the Default context using this builder. The withContext builder also provides a useful alternative to async when returning results from a coroutine.
  • coroutineScope – The coroutineScope builder is ideal for situations where a suspend function launches multiple coroutines that will run in parallel and where some action needs to take place only when all the coroutines complete. If those coroutines are launched using the coroutineScope builder, the calling function will not return until all child coroutines have completed. When using coroutineScope, a failure in any of the coroutines will result in the cancellation all other coroutines.
  • supervisorScope – Similar to the coroutineScope outlined above, with the exception that a failure in one child does not result in cancellation of the other coroutines.
  • runBlocking – Starts a coroutine and blocks the current thread until the coroutine reaches completion. This is typically the exact opposite of what is wanted from coroutines but is useful for testing code and when integrating legacy code and libraries. Otherwise to be avoided.

Jobs

Each call to a coroutine builder such as launch or async returns a Job instance which can, in turn, be used to track and manage the lifecycle of the corresponding coroutine. Subsequent builder calls from within the coroutine create new Job instances which will become children of the immediate parent Job forming a parentchild relationship tree where canceling a parent Job will recursively cancel all its children. Canceling a child does not, however, cancel the parent, though an uncaught exception within a child created using the launch builder may result in the cancellation of the parent (this is not the case for children created using the async builder which encapsulates the exception in the result returned to the parent).

The status of a coroutine can be identified by accessing the isActive, isCompleted and isCancelled properties of the associated Job object. In addition to these properties, a number of methods are also available on a Job instance. A Job and all of its children may, for example, be canceled by calling the cancel() method of the Job object, while a call to the cancelChildren() method will cancel all child coroutines.

The join() method can be called to suspend the coroutine associated with the job until all of its child jobs have completed. To perform this task and cancel the Job once all child jobs have completed, simply call the cancelAndJoin() method.

This hierarchical Job structure together with coroutine scopes form the foundation of structured concurrency, the goal of which is to ensure that coroutines do not run longer than they are required without the need to manually keep references to each coroutine.

Coroutines – Suspending and Resuming

To gain a better understanding of coroutine suspension, it helps to see some examples of coroutines in action. To start with, let’s assume a simple Android app containing a button that, when clicked, calls a function named startTask(). It is the responsibility of this function to call a suspend function named performSlowTask() using the Main coroutine dispatcher. The code for this might read as follows:

private val myCoroutineScope = CoroutineScope(Dispatchers.Main)

fun startTask(view: View) {
    myCoroutineScope.launch(Dispatchers.Main) {
        performSlowTask()
    }
}Code language: Swift (swift)

In the above code, a custom scope is declared and referenced in the call to the launch builder which, in turn, calls the performSlowTask() suspend function. Since startTask() is not a suspend function, the coroutine must be started using the launch builder instead of the async builder.

Next, we can declare the performSlowTask() suspend function as follows:

suspend fun performSlowTask() {
    Log.i(TAG, "performSlowTask before")
    delay(5_000) // simulates long-running task
    Log.i(TAG, "performSlowTask after")
}Code language: Swift (swift)

As implemented, all the function does is output diagnostic messages before and after performing a 5-second delay, simulating a long-running task. While the 5-second delay is in effect, the user interface will continue to be responsive because the main thread is not being blocked. To understand why it helps to explore what is happening behind the scenes.

First, the startTask() function is executed and launches the performSlowTask() suspend function as a coroutine. This function then calls the Kotlin delay() function passing through a time value. In fact the built-in Kotlin delay() function is itself implemented as a suspend function so is also launched as a coroutine by the Kotlin runtime environment. The code execution has now reached what is referred to as a suspend point which will cause the performSlowTask() coroutine to be suspended while the delay coroutine is running. This frees up the thread on which performSlowTask() was running and returns control to the main thread so that the UI is unaffected.

Once the delay() function reaches completion, the suspended coroutine will be resumed and restored to a thread from the pool where it can display the Log message and return to the startTask() function.

When working with coroutines in Android Studio suspend points within the code editor are marked as shown in the figure below:

Figure 63-1

Returning Results from a Coroutine

The above example ran a suspend function as a coroutine but did not demonstrate how to return results. Suppose, however, that the performSlowTask() function is required to return a string value which is to be displayed to the user via a TextView object.

To do this, we need to rewrite the suspend function to return a Deferred object. A Deferred object is essentially a commitment to provide a value at some point in the future. By calling the await() function on the Deferred object, the Kotlin runtime will deliver the value when it is returned by the coroutine. The code in our startTask() function might, therefore, be rewritten as follows:

fun startTask(view: View) {

    coroutineScope.launch(Dispatchers.Main) {
        statusText.text = performSlowTask().await()
    }
}Code language: Kotlin (kotlin)

The problem now is that we are having to use the launch builder to start the coroutine since startTask() is not a suspend function. As outlined earlier in this chapter, it is only possible to return results when using the async builder. To get around this, we have to adapt the suspend function to use the async builder to start another coroutine that returns a Deferred result:

suspend fun performSlowTask(): Deferred<String> =
    coroutineScope.async(Dispatchers.Default) {
        Log.i(TAG, "performSlowTask before")
        delay(5_000)
        Log.i(TAG, "performSlowTask after")
    return@async "Finished"
}Code language: Swift (swift)

Now when the app runs, the “Finished” result string will be displayed on the TextView object when the performSlowTask() coroutine completes. Once again, the wait for the result will take place in the background without blocking the main thread.

Using withContext

As we have seen, coroutines are launched within a specified scope and using a specific dispatcher. By default, any child coroutines will inherit the same dispatcher as that used by the parent. Consider the following code designed to call multiple functions from within a suspend function:

fun startTask(view: View) {

    coroutineScope.launch(Dispatchers.Main) {
        performTasks()
    }
}
 
suspend fun performTasks() {
    performTask1()
    performTask2()
    performTask3()
}
 
suspend fun performTask1() {
    Log.i(TAG, "Task 1 ${Thread.currentThread().name}")
}
 
suspend fun performTask2() {
    Log.i(TAG, "Task 2 ${Thread.currentThread().name}")
}
 
suspend fun performTask3 () {
    Log.i(TAG, "Task 3 ${Thread.currentThread().name}")
}Code language: Swift (swift)

Since the performTasks() function was launched using the Main dispatcher, all three of the functions will default to the main thread. To prove this, the functions have been written to output the name of the thread in which they are running. On execution, the Logcat panel will contain the following output:

Task 1 main
Task 2 main
Task 3 mainCode language: plaintext (plaintext)

Imagine, however, that the performTask2() function performs some network-intensive operations more suited to the IO dispatcher. This can easily be achieved using the withContext launcher which allows the context of a coroutine to be changed while still staying in the same coroutine scope. The following change switches the performTask2() coroutine to an IO thread:

suspend fun performTasks() {
    performTask1()
    withContext(Dispatchers.IO) { performTask2() }
    performTask3()
}Code language: Kotlin (kotlin)

When executed, the output will read as follows indicating that the Task 2 coroutine is no longer on the main thread:

Task 1 main
Task 2 DefaultDispatcher-worker-1
Task 3 mainCode language: plaintext (plaintext)

The withContext builder also provides an interesting alternative to using the async builder and the Deferred object await() call when returning a result. Using withContext, the code from the previous section can be rewritten as follows:

fun startTask(view: View) {

    coroutineScope.launch(Dispatchers.Main) {
          statusText.text = performSlowTask()
    }
}
 
suspend fun performSlowTask(): String =
    withContext(Dispatchers.Main) {
        Log.i(TAG, "performSlowTask before")
        delay(5_000)
        Log.i(TAG, "performSlowTask after")
 
        return@withContext "Finished"
    }
}Code language: Kotlin (kotlin)

Coroutine Channel Communication

Channels provide a simple way to implement communication between coroutines including streams of data. In the simplest form this involves the creation of a Channel instance and calling the send() method to send the data. Once sent, transmitted data can be received in another coroutine via a call to the receive() method of the same Channel instance.

The following code, for example, passes six integers from one coroutine to another:

import kotlinx.coroutines.channels.*
.
.
val channel = Channel<Int>()
 
suspend fun channelDemo() {
    coroutineScope.launch(Dispatchers.Main) { performTask1() }
    coroutineScope.launch(Dispatchers.Main) { performTask2() }
}
 
suspend fun performTask1() {
    (1..6).forEach {
        channel.send(it)
    }
}

suspend fun performTask2() {
    repeat(6) {
        Log.d(TAG, "Received: ${channel.recieve()}")
    }
}Code language: Kotlin (kotlin)

When executed, the following logcat output will be generated:

Received: 1
Received: 2
Received: 3
Received: 4
Received: 5
Received: 6Code language: plaintext (plaintext)

Summary

Kotlin coroutines provide a simpler and more efficient approach to performing asynchronous tasks than that offered by traditional multi-threading. Coroutines allow asynchronous tasks to be implemented in a structured way without the need to implement the callbacks associated with typical thread-based tasks. This chapter has introduced the basic concepts of coroutines including jobs, scope, builders, suspend functions, structured concurrency, and channel-based communication.