On Demand Modules

1. Introduction

Google Play's app serving model, called Dynamic Delivery, uses Android App Bundles to generate and serve optimized APKs for each user's device configuration, so users download only the code and resources they need to run your app. You no longer have to build, sign, and manage multiple APKs to support different devices, and users get smaller, more optimized downloads.

Most app projects won't require much effort to build app bundles that support serving optimized APKs using Dynamic Delivery. For example, if you already organize your app's code and resources according to established conventions, you can build signed Android App Bundles using Android Studio and upload them to Google Play. Dynamic Delivery then becomes an automatic benefit.

The benefits of Dynamic Delivery also allow you to modularize app features that aren't required at install time by adding dynamic feature modules to your app project and including them in your app bundle. Through Dynamic Delivery, your users can then download and install your app's dynamic features on-demand. However, creating on-demand modules requires more effort and possible refactoring of your app. So, consider carefully which of your app's features would benefit the most from being available to users on-demand.

This page helps you configure your app to support Dynamic Delivery and add a dynamic feature module to your app project. Before you begin, make sure you're using Android Studio 3.2 or higher and Android Gradle Plugin 3.2.0 or higher.

What are on demand modules

Dynamic feature modules allow you to separate certain features and resources from the base module of your app and include them in your app bundle. Through Dynamic Delivery, users can later download and install those components on demand after they've already installed the base APK of your app.

For example, consider a text messaging app that includes functionality for capturing and sending picture messages, but only a small percentage of users send picture messages. It may make sense to include picture messaging as a downloadable dynamic feature module. That way, the initial app download is smaller for all users and only those users who send picture messages need to download that additional component.

Keep in mind, this type of modularization requires more effort and possibly refactoring your app's existing code, so consider carefully which of your app's features would benefit the most from being available to users on-demand. Android App Bundles provide some additional options that help you transition your app towards supporting fully on-demand features. These options are described later in this section.

When to use dynamic feature modules

Consider a text messaging app that includes functionality for capturing and sending picture messages, but only a small percentage of users send picture messages. It may make sense to include picture messaging as a downloadable dynamic feature module. That way, the initial app download is smaller for all users and only those users who send picture messages need to download that additional component.

What you will build

In this codelab you'll be working on On Demand Sample, an app that does not much more than opening a different activity when you press a button. The trick is that all these activities will be downloaded on demand from Google Play when running the application on a device with Lollipop 5.0 (API Level 21) or above.

What you're going to learn

  • Converting a module into an on-demand one
  • Adding Play Core library to your project
  • Checking if an on-demand module is already installed on the device
  • Request the immediate or deferred installation of an on-demand module
  • Handle download/installation status callback for your on-demand modules
  • Request the deferred uninstallation of an on-demand module

What you'll need

  • Android Studio v3.4+
  • Android NDK because one of the dynamic modules is native.
  • A Google Play console account.

2. Getting set up

Step 1 - Download the Code

Click the following link to download all the code for this codelab:

Or if you prefer you can clone the navigation codelab from GitHub:

$ git clone -b codelab_start https://github.com/android/codelab-android-dynamic-features

Step 2 - Open the project

Depending on the version of Android Studio that you're using, when you're going to open the project in Android Studio for the first time, you maybe asked to update some of the gradle build files.

If you encounter a problem at this point, please open an issue on the codelab issue tracker.

Alternatively you can issue the following command on the root folder of the project (where you've cloned the source code):

Run from the command line to build the project

./gradlew build

Step 3 - Run the app

Run the app. You should see the following screens:

You can press any of the top three buttons to navigate to another simple activity from which you can navigate back into the main activity. Pressing the lower button opens a popup showing the assets included in one of the modules (as shown above).

This is the structure of the main module as seen in Android Studio:

1d0c883dbbad9740.png

As of now, the four feature modules are compiled into the application as libraries. As you can see the code is quite simple, but we're trying to cover all the possibilities with one module in Java, one in Kotlin, one in C, and one with only resources.

These are the structures of these modules as seen in Android Studio:

The starting code contains:

  1. MainActivity.kt: The main activity which shows the buttons to recall the other modules.
  2. activity_main.xml: The main activity activity layout.
  3. JavaSampleActivity.java: The activity in the Java module.
  4. KotlinSampleActivity.kt: The activity in the Kotlin module.
  5. NativeSampleActivity.kt: The activity in the native module. This is loading the native component of the native module and calling the function defined in C.
  6. build.gradle: The build files, one for every module.

3. Add Play Core library to your app

Play Core Library provides APIs to download and install modules as required. When requested, Google Play Store pushes only the code and resources needed for that module to the device. Before you can start using the Play Core Library, you need to first import it into your app module as a Gradle dependency, as shown below:

app/build.gradle

implementation "com.google.android.play:core:${versions.playcore}"

So that the complete dependency, in the main module gradle.build file, became:

build.gradle

dependencies {
    implementation project(':kotlin')
    implementation project(':java')
    implementation project(':native')
    implementation project(':assets')

    // Libraries which can be re-used in other modules should use the `api` keyword.
    // This way they can be shared with dependent feature modules.

    implementation 'androidx.annotation:annotation:1.1.0'
    api 'androidx.appcompat:appcompat:1.1.0'
    api 'androidx.constraintlayout:constraintlayout:1.1.3'
    api 'com.google.android.material:material:1.0.0'

    // *** Add the line below
    implementation "com.google.android.play:core:${versions.playcore}"

    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${versions.kotlin}"
}

You should get the most current version of the Play Core library from here and update the correct version in the top module gradle.build file:

build.gradle

ext.versions = [
        'compileSdk'        : 29,
        'minSdk'            : 16,
        'targetSdk'         : 29,
        'kotlin'            : '1.3.50',
        'playcore'          : '1.6.4'
]

Make sure to Sync Now to sync your project with the changed gradle files and check that Android Studio is happy with the changes.

4. Add support for Split Install API in your app

For our application to be able to immediately access code and resources from a downloaded module, without requiring an app restart, we need to enable the SplitCompat Library.

To achieve this we can modify the AndroidManifest.xml so that the application is using the SplitCompatApplication as the base class. For this, we have to add this android:name to the application tag

**app/src/main/AndroidManifest.**xml

android:name="com.google.android.play.core.splitcompat.SplitCompatApplication"

You can read more about this on the Play Core library guide.

The complete application tag became:

**app/src/main/AndroidManifest.**xml

<application
android:name="com.google.android.play.core.splitcompat.SplitCompatApplication"
    android:allowBackup="true"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:roundIcon="@mipmap/ic_launcher_round"
    android:supportsRtl="true"
    android:theme="@style/AppTheme">
    <activity android:name=".MainActivity">
        <intent-filter>
            <action android:name="android.intent.action.MAIN" />
            <action android:name="android.intent.action.VIEW" />

            <category android:name="android.intent.category.LAUNCHER" />
        </intent-filter>
    </activity>
</application>

In addition, you need to enable SplitCompat for any activity that your app loads from a feature module.

We will add SplitCompat library to our activities later in the codelab.

5. Convert the asset library to be an on-demand module

There are a couple of steps to convert the asset library to be an on-demand module:

  1. Add the <dist:module /> tag in its AndroidManifest.xml file.
  2. Change the plugin used in its build.gradle file.

This is the tag to add to the AndroidManifest.xml of the asset module

features/assets/src/main/AndroidManifest.xml

<dist:module dist:title="@string/module_assets">
    <dist:delivery>
        <dist:on-demand/>
    </dist:delivery>
    <dist:fusing dist:include="true" />
</dist:module>

As explained in the documentation:

  • dist:title: Specify a user-facing title for the module. The platform uses this title to identify the module to users when, for example, confirming whether the user wants to download the module.
  • dist:onDemand Specify that you want to have the module available as on-demand delivery. If you do not enable this option, the dynamic feature is available when a user first downloads and installs your app.
  • dist:fusing: Specify if you want this module to be available to devices running KitKat and lower and include it in multi-APKs (when dist:include is set to true).

This is the complete AndroidManifest.xml file:

features/assets/src/main/AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<!-- This feature module only contains a single assets file relevant to the Android app. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:dist="http://schemas.android.com/apk/distribution"
    package="com.google.android.samples.dynamicfeatures.ondemand.assets">

    <dist:module dist:title="@string/module_assets">
        <dist:delivery>
            <dist:on-demand/>
        </dist:delivery>
        <dist:fusing dist:include="true" />
    </dist:module>

    <!-- This feature module doesn't contain code. -->
    <application android:hasCode="false" />

</manifest>

Next step is to reverse the dependency structure. Previously the base app module was depending on the asset module, now we need to reverse this.

The end result is going to have all the on demand modules to depend on the base app module:

4e6bca47163c4eb0.png

To achieve this, we need to modify the build.gradle for the assets module to use a different plugin:

features/assets/build.gradle

// We need to remove the following line in the plugins block:
// id 'com.android.library'
// and add one for dynamic features, so it looks like:
plugins {
  id 'com.android.dynamic-feature'
}

We then need to add a dependency to the app module into the same build.gradle file:

features/assets/build.gradle

dependencies {
    implementation project(':app')
}

This is the complete build.gradle file after these changes:

features/assets/build.gradle

// *** Modify the following line
plugins {
 id 'com.android.dynamic-feature'
}


android {
    compileSdkVersion versions.compileSdk
    defaultConfig {
        minSdkVersion versions.minSdk
        targetSdkVersion versions.targetSdk
    }
}

dependencies {
    implementation project(':app')
}

Given that the Play Core library is using a name string to identify a module, makes sense to create a resource where we keep these strings.

Create a new string resources in your app module with the name feature_names.xml. We're creating the resource in the app module because this is the module that is seen by all the on-demand modules, as explained in the previous step where we reversed the dependency between app and assets modules.

f335d000a920249.png

We're going to add a string resource named module_assets this is the name used in the <dist:module /> block above:

app/src/main/res/values/feature_names.xml

<?xml version="1.0" encoding="utf-8"?>

<resources>
    <string name="module_assets">assets</string>
</resources>

Now we can use this string to instruct gradle that assets it's now an on-demand module adding to the app's build.gradle file a dynamicFeature command, using the following syntax:

app/build.gradle

dynamicFeatures = [':assets']

The other change required change, is to remove the dependency from the assets module. This is the line to remove from the app's build.gradle file:

app/build.gradle

implementation project(':assets')

You can find on the documentation more information on the available options for the build configuration of these dynamic feature modules.

This is the complete build.gradle file (at the root of the project) after these changes:

app/build.gradle

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

android {

    compileSdkVersion versions.compileSdk

    defaultConfig {
        applicationId names.applicationId
        minSdkVersion versions.minSdk
        targetSdkVersion versions.targetSdk
        versionCode 1
        versionName "1.0"
    }

    buildTypes {
        debug {}
        release {
            minifyEnabled true
        }
    }

    compileOptions {
        sourceCompatibility = '1.8'
        targetCompatibility = '1.8'
    }

    dynamicFeatures = [':assets'] // *** Add this line
}

dependencies {
    // implementation project(':assets') *** Comment this line
    implementation project(':kotlin')
    implementation project(':java')
    implementation project(':native')

    // Libraries which can be re-used in other modules should use the `api` keyword.
    // This way they can be shared with dependent feature modules.

    implementation 'androidx.annotation:annotation:1.0.1'
    implementation 'androidx.appcompat:appcompat:1.0.2'
    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
    implementation 'com.google.android.material:material:1.0.0'
    implementation "com.google.android.play:core:${versions.playcore}"
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${versions.kotlin}"
}

You can now run the application locally and you can see that it works correctly with no changes... So are we done?

Not really!

Running locally from Android Studio, we're installing all the APKs splits to the emulator or device. To test an on-demand module we need to create a release Android App Bundle and distributing it through the Play Store.

6. Load the sample app to Google Play

For a step by step guide on how to publish a new application on the Google Play Store, you can follow the Play Console Guide on how to upload an app.

However, If you try to publish and run the application at this moment, it's going to crash with an exception when you try to load the file from the assets module:

adb logcat

Caused by: java.io.FileNotFoundException: assets.txt

We're not any more able to load the assets.txt resource file included in the asset module. This is happening because our module is now an on-demand module and we need to actually download it!

If you download and run the current version of the application on a device running Lollipop (Android v5.0) or newer, you're going to experience this exception.

Keep in mind that downloading and running the application on an older OS version (e.g. KitKat, v4.4) does not result in an exception because Google Play is going to serve to the device a fused APK containing all the modules that includes the option:

<dist:fusing dist:include="true" />

In the module's AndroidManifest.xml file, like we've done now for the assets module.

7. Download an on-demand module

Check for the presence of an on-demand module

The Play Core library makes available some methods to work with on-demand modules. We can access these methods through the SplitInstallManager class.

Start creating an instance of SplitInstallManager in the MainActivity using the static create() method of SplitInstallManagerFactory class:

MainActivity.kt

private lateinit var manager: SplitInstallManager

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    manager = SplitInstallManagerFactory.create(this)

    initializeViews()
}

The SplitInstallManager allows us to check if an on-demand module is installed, and avoid crashes - like the one we saw before - if we try to access a module that is not installed.

First, we want to retrieve the module name from the feature_names.xml resource:

MainActivity.kt

private val moduleAssets by lazy { getString(R.string.module_assets) }

Then, we can add a check in the displayAssets function to access the resources in the asset module only if this is installed:

MainActivity.kt

private fun displayAssets() {
    if (manager.installedModules.contains(moduleAssets)) {
        // Get the asset manager with a refreshed context, to access content of newly installed apk.
        val assetManager = createPackageContext(packageName, 0).assets
        // Now treat it like any other asset file.
        val assets = assetManager.open("assets.txt")
        val assetContent = assets.bufferedReader()
                .use {
                    it.readText()
                }

        AlertDialog.Builder(this)
                .setTitle("Asset content")
                .setMessage(assetContent)
                .show()
    } else {
        toastAndLog("The assets module is not installed")
    }
}

If we now recompile the project and test it through the Google Play Console, we can see that the application is not crashing but it's showing the warning message.

94053fc2ab240713.png

Time to download the asset module!

Request an on-demand module

When your app needs to use a dynamic feature module, it can request one while it's in the foreground through the SplitInstallManager class. When making a request, your app needs to specify the name of the module as defined by the split element in the target module's manifest. When you create a dynamic feature module using Android Studio, the build system uses the Module name you provide to inject this property into the module's manifest at compile time. For more information, read about the Dynamic feature module manifests.

Download the asset module

To download an on-demand module we need to create a SplitInstallRequest and then ask the SplitInstallManager to install the request we just created.

Following what we've done before, we can add this logic to the displayAssets function, when the module is not installed:

MainActivity.kt

val request = SplitInstallRequest.newBuilder()
        .addModule(moduleAssets)
        .build()

manager.startInstall(request)
       .addOnCompleteListener {toastAndLog("Module ${moduleAssets} installed") }
       .addOnSuccessListener {toastAndLog("Loading ${moduleAssets}") }
       .addOnFailureListener { toastAndLog("Error Loading ${moduleAssets}") }

The complete method became:

MainActivity.kt

private fun displayAssets() {
    if (manager.installedModules.contains(moduleAssets)) {
        // Get the asset manager with a refreshed context, to access content of newly installed apk.
        val assetManager = createPackageContext(packageName, 0).assets
        // Now treat it like any other asset file.
        val assets = assetManager.open("assets.txt")
        val assetContent = assets.bufferedReader()
                .use {
                    it.readText()
                }

        AlertDialog.Builder(this)
                .setTitle("Asset content")
                .setMessage(assetContent)
                .show()
    } else {
        toastAndLog("The assets module is not installed")

        // We just added the following lines
        val request = SplitInstallRequest.newBuilder()
                .addModule(moduleAssets)
                .build()

        manager.startInstall(request)
               .addOnCompleteListener {toastAndLog("Module ${moduleAssets} installed") }
               .addOnSuccessListener {toastAndLog("Loading ${moduleAssets}") }
               .addOnFailureListener { toastAndLog("Error Loading ${moduleAssets}") }
    }
}

If we now we build a signed bundle and upload it to the Google Play Store, we can see that the first time we press the assets button, we still get the toast notifying that the module is not installed, but the message is followed by another toast advising that the module is now installing.

If you select again the assets button, you'll see that the on-demand module is now installed on the device and the assets is displayed:

a86ffc82dfae2eb.png

Obviously, this way to download the on-demand module is not optimal. In the next section we're going to see how to register a listener to the SplitInstallManager events and notify the users.

If you encounter any problem you can refer to the source code on github. This step is identified by the tag codelab_step1.

8. SplitInstallManager listener

A better way to manage the download of a missing on-demand module is to provide some feedback to the user, when we start the download of the feature. This can include an on-screen progress bar informing the user of what is happening.

To do this, we need to add some UI components to our user interface.

Step 1 - Add the UI elements to show the download progress

The end result we want to achieve, when we launch an on-demand module that is not installed is to visualize a group of components (progress bar and some text to inform the user of what is going on) hiding what we previously had on the screen. Avoiding in this way that the user request a second time the same module.

Below are the components that we're adding after the others in the activity_main.xml layout:

activity_main.xml

<ProgressBar
    android:id="@+id/progress_bar"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginStart="8dp"
    android:layout_marginEnd="8dp"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    style="?android:attr/progressBarStyleHorizontal" />

<TextView
    android:id="@+id/progress_text"
    style="@android:style/TextAppearance.DeviceDefault.Medium"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginStart="8dp"
    android:layout_marginEnd="8dp"
    android:text="@string/loading"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@+id/progress_bar" />

<androidx.constraintlayout.widget.Group
    android:id="@+id/progress"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:visibility="gone"
    app:constraint_referenced_ids="progress_bar,progress_text" />

<androidx.constraintlayout.widget.Group
    android:id="@+id/buttons"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:constraint_referenced_ids="btn_load_native,btn_load_assets,btn_load_java,
    btn_load_kotlin,instructions" />

You can see the complete version of the activity_main.xml file at this stage on the github repository.

On top of the new UI components, we've added a couple of widget groups to hide and show the components on the screen.

Step 2 - Adding the new UI widgets to the MainActivity class

We're going to apply some changes to the MainActivity.kt class. In particular we are going to:

  1. Create new reference variables for the new widgets
  2. Retrieve the widget instances
  3. Add some functions to handle showing and hiding the widgets
  4. Add a helper function to show messages integrated with the widgets visibility
  5. Update the displayAssets function so that it uses the new UI components

These are the variables we're going to use to handle the UI widgets:

MainActivity.kt

private lateinit var progress: Group
private lateinit var buttons: Group
private lateinit var progressBar: ProgressBar
private lateinit var progressText: TextView

We need to update the initializeViews function to retrieve their instances:

MainActivity.kt

private fun initializeViews() {
    buttons = findViewById(R.id.buttons)
    progress = findViewById(R.id.progress)
    progressBar = findViewById(R.id.progress_bar)
    progressText = findViewById(R.id.progress_text)

    setupClickListener()
}

We're now going to add these three functions to allow us to update the UI as needed:

MainActivity.kt

private fun updateProgressMessage(message: String) {
    if (progress.visibility != View.VISIBLE) displayProgress()
    progressText.text = message
}

/** Display progress bar and text. */
private fun displayProgress() {
    progress.visibility = View.VISIBLE
    buttons.visibility = View.GONE
}

/** Display buttons to accept user input. */
private fun displayButtons() {
    progress.visibility = View.GONE
    buttons.visibility = View.VISIBLE
}

Finally we can integrate the logic to handle the new UI in the displayAssets function, replacing the previous code with the following:

MainActivity.kt

private fun displayAssets() {
    updateProgressMessage("Loading module $moduleAssets")
    if (manager.installedModules.contains(moduleAssets)) {
        updateProgressMessage("Already installed")
        // Get the asset manager with a refreshed context, to access content of newly installed apk.
        val assetManager = createPackageContext(packageName, 0).assets
        // Now treat it like any other asset file.
        val assets = assetManager.open("assets.txt")
        val assetContent = assets.bufferedReader()
                .use {
                    it.readText()
                }

        AlertDialog.Builder(this)
                .setTitle("Asset content")
                .setMessage(assetContent)
                .show()

        displayButtons()
    } else {
        updateProgressMessage("Starting install for $moduleAssets")
        val request = SplitInstallRequest.newBuilder()
                .addModule(moduleAssets)
                .build()

        manager.startInstall(request)
               .addOnCompleteListener {
                   displayAssets()
               }
               .addOnSuccessListener {
                   toastAndLog("Loading ${moduleAssets}")
               }
               .addOnFailureListener {
                   toastAndLog("Error Loading ${moduleAssets}")
                   displayButtons()
               }
    }
}

The MainActivity.kt class, at this stage, is available on github.

Compiling a release Bundle and testing on the device show us the new UI:

f5cd128c6cc03a70.png

This works, but we can do a much better job using a SplitInstallStateUpdatedListener.

Step 3 - Add a SplitInstallStateUpdatedListener

To handle the download of the on-demand modules we can implement a SplitInstallStateUpdatedListener. This allows us to get notified when there's a change in the SplitInstallManager session status. Possible values are:

SplitInstallSessionStatus

int UNKNOWN = 0;
int PENDING = 1;
int REQUIRES_USER_CONFIRMATION = 8;
int DOWNLOADING = 2;
int DOWNLOADED = 3;
int INSTALLING = 4;
int INSTALLED = 5;
int FAILED = 6;
int CANCELING = 9;
int CANCELED = 7;

In this sample application we're going to handle the following status codes:

  • DOWNLOADING - The on-demand module is downloading
  • REQUIRES_USER_CONFIRMATION - This may occur when attempting to download a sufficiently large module
  • INSTALLED - The requested on-demand module has been installed
  • INSTALLING - The on-demand module has been downloaded and is now installing
  • FAILED - Download or installation of the on-demand module has failed

It's important to understand that this listener is going to receive updates for all on-demand modules we've asked to install and it's our duty to check the module name in the callback function. For this reason we're going to add the support for all the modules, even if the only one already configured as an on-demand module at this moment is the assets one.

These are the steps we need to implement

  1. Add the missing on-demand module names to feature_names.xml
  2. Define the module names in the MainActivity class
  3. Define the listener itself
  4. Handle the registration of the Listener taking care of the activity lifecycle.
  5. Add a helper function to launch the modules

Let's start adding the missing on-demand modules names:

feature_names.xml

<resources>
    <string name="module_feature_kotlin">kotlin</string>
    <string name="module_feature_java">java</string>
    <string name="module_native">native</string>
    <string name="module_assets">assets</string>
</resources>

These are going to be used in the MainActivity class:

MainActivity.kt

private val moduleKotlin by lazy { getString(R.string.module_feature_kotlin) }
private val moduleJava by lazy { getString(R.string.module_feature_java) }
private val moduleNative by lazy { getString(R.string.module_native) }
private val moduleAssets by lazy { getString(R.string.module_assets) }

We can now implement the SplitInstallManager listener:

MainActivity.kt

/** Listener used to handle changes in state for install requests. */
private val listener = SplitInstallStateUpdatedListener { state ->
    val multiInstall = state.moduleNames().size > 1
    val names = state.moduleNames().joinToString(" - ")
    when (state.status()) {
        SplitInstallSessionStatus.DOWNLOADING -> {
            //  In order to see this, the application has to be uploaded to the Play Store.
            displayLoadingState(state, "Downloading $names")
        }
        SplitInstallSessionStatus.REQUIRES_USER_CONFIRMATION -> {
            /*
              This may occur when attempting to download a sufficiently large module.

              In order to see this, the application has to be uploaded to the Play Store.
              Then features can be requested until the confirmation path is triggered.
             */
            startIntentSender(state.resolutionIntent()?.intentSender, null, 0, 0, 0)
        }
        SplitInstallSessionStatus.INSTALLED -> {
            onSuccessfulLoad(names, launch = !multiInstall)
        }

        SplitInstallSessionStatus.INSTALLING -> displayLoadingState(state, "Installing $names")
        SplitInstallSessionStatus.FAILED -> {
            toastAndLog("Error: ${state.errorCode()} for module ${state.moduleNames()}")
        }
    }
}

In the listener we are using a helper function to display the loading state, we need to add this to the MainActivity.kt class:

/** Display a loading state to the user. */
private fun displayLoadingState(state: SplitInstallSessionState, message: String) {
    displayProgress()

    progressBar.max = state.totalBytesToDownload().toInt()
    progressBar.progress = state.bytesDownloaded().toInt()

    updateProgressMessage(message)
}

The listener handles the case where we can have requested to install more than one on-demand module:

MainActivity.kt

val multiInstall = state.moduleNames().size > 1 

We can then register and unregister the Listener in the onResume() and onPause() callbacks

MainActivity.kt

override fun onResume() {
    // Listener can be registered even without directly triggering a download.
    manager.registerListener(listener)
    super.onResume()
}

override fun onPause() {
    // Make sure to dispose of the listener once it's no longer needed.
    manager.unregisterListener(listener)
    super.onPause()
}

We then require a new helper function to manage launching the modules:

MainActivity.kt

/**
 * Define what to do once a feature module is loaded successfully.
 * @param moduleName The name of the successfully loaded module.
 * @param launch `true` if the feature module should be launched, else `false`.
 */
private fun onSuccessfulLoad(moduleName: String, launch: Boolean) {
    if (launch) {
        when (moduleName) {
            moduleKotlin -> launchActivity(kotlinSampleClassname)
            moduleJava -> launchActivity(javaSampleClassname)
            moduleNative -> launchActivity(nativeSampleClassname)
            moduleAssets -> displayAssets()
        }
    }

    displayButtons()
}

We can now modify the onClickListener to go through this helper function and, as a last step, cleanup the displayAssets function:

MainActivity.kt

private fun displayAssets() {
    // Get the asset manager with a refreshed context, to access content of newly installed apk.
    val assetManager = createPackageContext(packageName, 0).assets
    // Now treat it like any other asset file.
    val assets = assetManager.open("assets.txt")
    val assetContent = assets.bufferedReader()
            .use {
                it.readText()
            }

    AlertDialog.Builder(this)
            .setTitle("Asset content")
            .setMessage(assetContent)
            .show()
}

private val clickListener by lazy {
    View.OnClickListener {
        when (it.id) {
            R.id.btn_load_kotlin -> launchActivity(kotlinSampleClassname)
            R.id.btn_load_java -> launchActivity(javaSampleClassname)
            R.id.btn_load_native -> launchActivity(nativeSampleClassname)
            R.id.btn_load_assets -> loadAndLaunchModule(moduleAssets)
        }
    }
}

At this point we just miss to define the loadAndLaunchModule function:

MainActivity.kt

/**
 * Load a feature by module name.
 * @param name The name of the feature module to load.
 */
private fun loadAndLaunchModule(name: String) {
    updateProgressMessage("Loading module $name")
    // Skip loading if the module already is installed. Perform success action directly.
    if (manager.installedModules.contains(name)) {
        updateProgressMessage("Already installed")
        onSuccessfulLoad(name, launch = true)
        return
    }

    // Create request to install a feature module by name.
    val request = SplitInstallRequest.newBuilder()
            .addModule(name)
            .build()

    // Load and install the requested feature module.
    manager.startInstall(request)

    updateProgressMessage("Starting install for $name")
}

Step 4 - Run your app

At this point you should run your app (to test the on-demand modules you need to upload the app on the Google Play Store, and make sure to uninstall the app). It should compile and have a similar behavior. The main difference is that we're now using the progress bar to show the loading taking place:

Excellent work! To see the current state of the code and all the changes check out:

9. Converting the other modules

To convert the rest of the modules, we start by updating the build gradle files and the Android Manifest.

Step 1 - Update the build.gradle files

We start from the app build.gradle file, all the module are now on-demand modules and are going to depend from these one, so, we're going to remove the dependency from the modules:

app/build.gradle

dependencies {
    // Libraries which can be re-used in other modules should use the `api` keyword.
    // This way they can be shared with dependent feature modules.

    implementation 'androidx.annotation:annotation:1.1.0'
    implementation 'androidx.appcompat:appcompat:1.0.2'
    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
    api 'com.google.android.material:material:1.0.0'
    api "com.google.android.play:core:${versions.playcore}"
    api "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${versions.kotlin}"
}

Next, we can add them a dynamic modules:

app/build.gradle

dynamicFeatures = [':kotlin',
                   ':java',
                   ':native',
                   ':assets']

Next we update the on-demand modules build gradle files. In this case we need to change the plugin line from com.android.library to com.android.dynamic-feature and then change the dependencies to the app module:

features/java**/build.gradle**

plugins {
  id 'com.android.dynamic-feature'
}
// Updated line

android {

    compileSdkVersion versions.compileSdk
    defaultConfig {
        minSdkVersion versions.minSdk
        targetSdkVersion versions.targetSdk
    }
}

dependencies {
    implementation project(':app')
}

features/kotlin**/build.gradle**

plugins {
  id 'com.android.dynamic-feature' // Updated line
  id 'kotlin-android'
}

android {
    compileSdkVersion versions.compileSdk
    defaultConfig {
        minSdkVersion versions.minSdk
        targetSdkVersion versions.targetSdk
    }
}

dependencies {
    implementation project(':app')
}

features/native**/build.gradle**

plugins {
  id 'com.android.dynamic-feature' // Updated line
  id 'kotlin-android'
}

android {

    compileSdkVersion versions.compileSdk
    defaultConfig {
        minSdkVersion versions.minSdk
        targetSdkVersion versions.targetSdk

        externalNativeBuild {
            cmake {
                cppFlags "-std=c++11"
            }
        }
    }

    externalNativeBuild {
        cmake {
            path "src/main/cpp/CMakeLists.txt"
        }
    }
}

dependencies {
    implementation project(':app')
}

Step 1 - Update the AndroidManifest files for the three modules

We're going to add the information that these three modules are now on-demand modules and that they need to be fused when delivered to devices running KitKat or earlier OS:

features/java**/src/main/AndroidManifest.xml**

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:dist="http://schemas.android.com/apk/distribution"
    package="com.google.android.samples.dynamicfeatures.ondemand.java">

    <dist:module dist:title="@string/module_feature_java">
        <dist:delivery>
            <dist:on-demand/>
        </dist:delivery>
        <dist:fusing dist:include="true" />
    </dist:module>

    <application>
        <activity android:name="com.google.android.samples.dynamicfeatures.ondemand.JavaSampleActivity">
            <intent-filter>
                <action android:name="android.intent.action.VIEW" />
            </intent-filter>
        </activity>
    </application>

</manifest>

features/kotlin**/****java/src/main/AndroidManifest.xml**

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:dist="http://schemas.android.com/apk/distribution"
    package="com.google.android.samples.dynamicfeatures.ondemand.kotlin">

    <dist:module dist:title="@string/module_feature_kotlin">
        <dist:delivery>
            <dist:on-demand/>
        </dist:delivery>
        <dist:fusing dist:include="true" />
    </dist:module>

    <application>
        <activity android:name="com.google.android.samples.dynamicfeatures.ondemand.KotlinSampleActivity">
            <intent-filter>
                <action android:name="android.intent.action.VIEW" />
            </intent-filter>
        </activity>
    </application>

</manifest>

features/native**/****java/src/main/AndroidManifest.xml**

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:dist="http://schemas.android.com/apk/distribution"
    package="com.google.android.samples.dynamicfeatures.ondemand.ccode">

    <dist:module dist:title="@string/module_native">
        <dist:delivery>
            <dist:on-demand/>
        </dist:delivery>
        <dist:fusing dist:include="true" />
    </dist:module>

    <application>
        <activity android:name="com.google.android.samples.dynamicfeatures.ondemand.NativeSampleActivity">
            <intent-filter>
                <action android:name="android.intent.action.VIEW" />
            </intent-filter>
        </activity>
    </application>

</manifest>

Step 3 - Update the MainActivity class

At this moment we've done all the heavy lifting in this class previously and it's just a matter of updating the clickListener:

MainActivity.kt

private val clickListener by lazy {
   View.OnClickListener {
       when (it.id) {
           R.id.btn_load_kotlin -> loadAndLaunchModule(moduleKotlin)
           R.id.btn_load_java -> loadAndLaunchModule(moduleJava)
           R.id.btn_load_native -> loadAndLaunchModule(moduleNative)
           R.id.btn_load_assets -> loadAndLaunchModule(moduleAssets)
       }
   }
}

At this point you should run your app (to test the on-demand modules you need to upload the app on the Google Play Store). It should download the additional on-demands modules but it's going to crash when trying to launch their activity.

Step 4 - Install the on-demand splits into the base context

The reason is that we need to attach the context of these activities to the base one. This can be done by overloading the attachBaseContext method in the first activity that is launched in every on-demand module and "installing" the module into the app context calling SplitCompat.installActivity(this). This takes care to load the activity's resources from the module into the context.

Instead of doing this for every class, we can design a small class that does just this and have the activities in each module inherit from it:

  1. Create a new BaseSplitActivity in the app module
  2. Modify the activities in the on-demand module to inherit from the BaseSplitActivity

We start creating a BaseSplitActivity class in the app module:

BaseSplitActivity.kt

package com.google.android.samples.dynamicfeatures

import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import com.google.android.play.core.splitcompat.SplitCompat

/**
 * This base activity unifies calls to attachBaseContext as described in:
 * https://developer.android.com/guide/app-bundle/playcore#invoke_splitcompat_at_runtime
 */
abstract class BaseSplitActivity : AppCompatActivity() {

    override fun attachBaseContext(newBase: Context?) {
        super.attachBaseContext(newBase)
        SplitCompat.installActivity(this)
    }

}

Then we can modify the three activities in the on-demand modules to inherit from this class:

JavaSampleActivity.kt

package com.google.android.samples.dynamicfeatures.ondemand;

import android.os.Bundle;
import com.google.android.samples.dynamicfeatures.BaseSplitActivity;
import com.google.android.samples.dynamicfeatures.ondemand.java.R;

import androidx.annotation.Nullable;

/** A simple activity displaying text written in Java. */
public class JavaSampleActivity extends BaseSplitActivity {

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_feature_java);
    }
}

KotlinSampleActivity.kt

package com.google.android.samples.dynamicfeatures.ondemand

import android.os.Bundle
import com.google.android.samples.dynamicfeatures.BaseSplitActivity
import com.google.android.samples.dynamicfeatures.ondemand.kotlin.R

/** A simple Activity displaying some text, written in Kotlin. */
class KotlinSampleActivity : BaseSplitActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_feature_kotlin)
    }
}

An additional note for the NativeSampleActivity class. As this is now an on demand module, we're going to use the SplitInstallHelper's loadLibrary() method to handle possible corner cases that may arise when the module has been installed. You can find more information in the documentation:

NativeSampleActivity.kt

package com.google.android.samples.dynamicfeatures.ondemand

import android.os.Bundle
import android.widget.TextView
import com.google.android.play.core.splitinstall.SplitInstallHelper
import com.google.android.samples.dynamicfeatures.BaseSplitActivity
import com.google.android.samples.dynamicfeatures.ondemand.ccode.R

/** A simple activity displaying some text coming through via JNI. */
class NativeSampleActivity : BaseSplitActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        SplitInstallHelper.loadLibrary(this, "hello-jni")

        setContentView(R.layout.activity_hello_jni)
        findViewById<TextView>(R.id.hello_textview).text = stringFromJNI()
    }

    /** Read a string from packaged native code. */
    external fun stringFromJNI(): String
}

Step 4 - Run your app

At this point you should run your app () and see that all the on-demand modules can be accessed.

Excellent work! To see the current state of the code and all the changes check out:

10. Deferred install and uninstall actions

The SplitInstallManager offers some additional API to control the deferred installation and uninstallation of on-demand modules.

You can refer to the documentation for further information, but the basic idea is that you can request to the Play Core library to download and install, in the future, the modules you requested. This is the "deferred install" that is available through the SplitInstallManager and the deferredInstall() method.

In some cases, Google Play may require user confirmation before satisfying a download request. For example, if a request requires a large download and the device is using mobile data. Deferred installation is useful to schedule these large downloads to a more suitable time without user confirmation.

In the same way, you can request a deferred uninstall of one or more on-demand modules using the SplitInstallManager's deferredUninstall() method.

It's important to understand that these methods are adding these operations to a queue that will be executed sometime in the future, typically up to a maximum of 24 hours. If you want to install immediately a module, you should create a SplitInstallRequest and call the SplitInstallManager's startInstall() method, using that request as argument.

If you need to uninstall immediately an on-demand module, for some testing purpose, the best way is to uninstall the application and reinstall it.

Another option is to release a new update of the application. In this case Play is not going to deliver the update for the module that we've uninstalled.

Let's now refactor the application to add these functionalities adding some UI controls.

Step 1 - Update the UI with some additional controls

We want to add some buttons to control these functionalities. This is the UI the we're going to build:

e148ecb59e57c58.png

We can add these three button to the activity_main.xml layout file:

activity_main**.xml**

<Button
    android:id="@+id/btn_install_all_now"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginStart="8dp"
    android:layout_marginEnd="8dp"
    android:text="@string/install_all_features_now"
    app:layout_constraintBottom_toTopOf="@+id/btn_install_all_deferred"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@+id/btn_load_assets" />

<Button
    android:id="@+id/btn_install_all_deferred"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginStart="8dp"
    android:layout_marginEnd="8dp"
    android:layout_marginBottom="8dp"
    android:text="@string/install_all_deferred"
    app:layout_constraintBottom_toTopOf="@+id/btn_request_uninstall"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@+id/btn_install_all_now" />

<Button
    android:id="@+id/btn_request_uninstall"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginStart="8dp"
    android:layout_marginEnd="8dp"
    android:layout_marginBottom="8dp"
    android:text="@string/request_uninstall"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@+id/btn_install_all_deferred" />

Then we have to adjust the "Show Assets" button's constraints to point to the "Install All Features Now" button:

activity_main.xml

<Button
    android:id="@+id/btn_load_assets"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginStart="8dp"
    android:layout_marginEnd="8dp"
    android:layout_marginBottom="8dp"
    android:text="@string/load_assets"

    app:layout_constraintBottom_toTopOf="@+id/btn_install_all_now"

    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@+id/btn_load_native" />

The last change we have to do to the layout file is to include all the buttons in the groups used to hide/show the controls when the app is showing the download/install progress.

activity_main.xml

<androidx.constraintlayout.widget.Group
    android:id="@+id/buttons"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:constraint_referenced_ids="btn_load_native,btn_load_assets,btn_load_java,
    btn_load_kotlin,instructions,btn_install_all_now,
    btn_install_all_deferred,btn_request_uninstall" />

Then we need to add a few strings to the resource file:

strings.xml

<resources>
    <string name="app_name">On Demand Sample</string>
    <string name="load_feature_kotlin">Start Kotlin Feature</string>
    <string name="load_feature_java">Start Java Feature</string>
    <string name="load_native_code">Start Native Feature</string>
    <string name="load_assets">Show assets</string>
    <string name="loading">Loading</string>
    <string name="instructions">Press a button to perform an action.</string>
    <string name="install_all_features_now">Install all features now</string>
    <string name="load_all_features_deferred">Load all features deferred</string>
    <string name="install_all_deferred">Install deferred</string>
    <string name="request_uninstall">Request module uninstall</string>
</resources>

This gives us the UI that we've shown above.

Step 2 - Add the logic to handle the additional actions

Now that we have the UI, we need to attach the logic to these buttons. We're going to do this in the MainActivity class.

First we want to implement the method that request to install all the module. No surprises here, we are going to build a SplitInstallRequest and process this with the SplitInstallManager's startInstall() method:

MainActivity.kt

/** Install all features but do not launch any of them. */
private fun installAllFeaturesNow() {
    // Request all known modules to be downloaded in a single session.
    val moduleNames = listOf(moduleKotlin, moduleJava, moduleNative, moduleAssets)
    val requestBuilder = SplitInstallRequest.newBuilder()

    moduleNames.forEach { name ->
        if (!manager.installedModules.contains(name)) {
            requestBuilder.addModule(name)
        }
    }

    val request = requestBuilder.build()

    manager.startInstall(request).addOnSuccessListener {
        toastAndLog("Loading ${request.moduleNames}")
    }.addOnFailureListener {
        toastAndLog("Failed loading ${request.moduleNames}")
    }
}

Next, we can use the SplitInstallManager's deferredInstall() method to request the installation of all the on-demand modules in the future:

MainActivity.kt

/** Install all features deferred. */
private fun installAllFeaturesDeferred() {

    val modules = listOf(moduleKotlin, moduleJava, moduleAssets, moduleNative)

    manager.deferredInstall(modules).addOnSuccessListener {
        toastAndLog("Deferred installation of $modules")
    }.addOnFailureListener {
        toastAndLog("Failed installation of $modules")
    }
}

Last, it's about requesting a deferred uninstall, again we use the SplitInstallManager, in this case its deferredUninstall() method:

MainActivity.kt

/** Request uninstall of all features. */
private fun requestUninstall() {

    toastAndLog("Requesting uninstall of all modules." +
            "This will happen at some point in the future.")

    val installedModules = manager.installedModules.toList()
    manager.deferredUninstall(installedModules).addOnSuccessListener {
        toastAndLog("Uninstalling $installedModules")
    }
}

At this point we just have to connect this functions with the UI buttons through their click listeners:

MainActivity.kt

/** Set all click listeners required for the buttons on the UI. */
private fun setupClickListener() {
    setClickListener(R.id.btn_load_kotlin, clickListener)
    setClickListener(R.id.btn_load_java, clickListener)
    setClickListener(R.id.btn_load_assets, clickListener)
    setClickListener(R.id.btn_load_native, clickListener)
    setClickListener(R.id.btn_install_all_now, clickListener)
    setClickListener(R.id.btn_install_all_deferred, clickListener)
    setClickListener(R.id.btn_request_uninstall, clickListener)
}

private val clickListener by lazy {
    View.OnClickListener {
        when (it.id) {
            R.id.btn_load_kotlin -> loadAndLaunchModule(moduleKotlin)
            R.id.btn_load_java -> loadAndLaunchModule(moduleJava)
            R.id.btn_load_native -> loadAndLaunchModule(moduleNative)
            R.id.btn_load_assets -> loadAndLaunchModule(moduleAssets)
            R.id.btn_install_all_now -> installAllFeaturesNow()
            R.id.btn_install_all_deferred -> installAllFeaturesDeferred()
            R.id.btn_request_uninstall -> requestUninstall()
        }
    }
}

Step 3 - Run the application

At this point you should run your app (to test the on-demand modules you need to upload the app on the Google Play Store) and see that all the on-demand modules can be accessed.

Excellent work! To see the current state of the code and all the changes check out:

11. Testing user confirmation for large downloads

So far we worked with very small on demand modules, way below the 10MB that is the current threshold to trigger an user to confirm the download. You can find more information about this on the documentation for the Play Core library.

The application we build so far already handle correctly this requirement, the only thing missing to test it is to have a large enough module to trigger the request.

The simplest way to test this is to add a file, larger than 10MB, to the assets folder of one of the modules.

Below you can see what happens when you try to install the java module when it's larger than 10MB:

a693002ebb95b98b.png

12. Congratulations

Congratulations! You've completed this codelab and in the process learned about:

  • Adding the Play Core library to your Project
  • Converting existing modules to on-demand modules
  • Use the SplitInstallManager to check if an on-demand module is installed
  • Create a SplitInstallRequest to install an on-demand module
  • Register a SplitInstallManager's listener
  • Use the listener to notify the user about the on-demand module install process
  • Correctly handle the baseContext in your on-demand modules
  • How to test on-demand module through the Google Play Store
  • Deferred Install/Uninstall

Excellent work! To see the end state of the code and all the changes check out:

The Play Core library supports a lot more than we could cover in this codelab, including in-app-updates. To learn more, head over to the Play Core library documentation.