Chapters

Hide chapters

Real-World Android by Tutorials

First Edition · Android 10 · Kotlin 1.4 · AS 4

Section I: Developing Real World Apps

Section 1: 7 chapters
Show chapters Hide chapters

10. Building a Dynamic Feature
Written by Ricardo Costeira

The App Bundle publishing format is here to stay. Starting in the second half of 2021, Google Play will require you to publish new apps with the App Bundle format. Moreover, if your app’s size exceeds 150 MB, it must use either Play Feature Delivery or Play Asset Delivery.

This chapter assumes you’re aware of the theory behind dynamic features explained in Chapter 9, “Dynamic Features Theory”. Now, you’ll work on refactoring a common feature module and turning it into a dynamic feature.

Along the way, you’ll learn:

  • How to create an app bundle.
  • How to refactor a library module to a dynamic feature module.
  • How to navigate with dynamic features using the Navigation component.
  • How to inject dependencies into dynamic features.
  • How to test dynamic feature module installs.

You’ll focus on working with a new feature module that you’ll turn into a dynamic feature model that lets users install the feature only if they want to use it.

PetSave’s new features

The PetSave team has been hard at work, and the app has two updates. Open the starter project to check them out.

Start by expanding features. You’ll notice there’s a new feature module called sharing. This feature lets the user share a specific animal on their social networks.

Figure 10.1 — The Sharing Feature
Figure 10.1 — The Sharing Feature

The code is similar to onboarding’s, so if you’re familiar with that code already, there’s not much to gain in exploring the module.

You navigate to this screen through a deep link, thanks to the app’s other new feature. Go to the animalsnearyou module and expand presentation. You’ll find two packages inside:

  • main: Home to the code of the animals near you main screen, which you’re already familiar with.
  • animaldetails: Contains the code for a new screen that shows an animal’s details.

This screen appears when you click an animal in the list. It shows the animal’s name, picture and a few other details.

Figure 10.2 — Animal Details Screen
Figure 10.2 — Animal Details Screen

At the top-right corner of the screen is a share icon. Clicking it triggers the deep link into the sharing feature. The code behind it is similar to what you’ve seen so far, but there’s one difference worth noting: This screen uses sealed classes to handle the view state, making the view state that handles code in the Fragment similar to the event handling code in the ViewModel.

In the long term, both animals near you and search will use this screen. For now, however, you’ll handle it as if it’s part of animals near you for simplicity.

With the introductions out of the way, it’s time to get to work. You’ll refactor the sharing module into an on-demand dynamic feature module. With this change, only users who want that feature need to download it.

Deciding how to create your dynamic feature

To create a dynamic feature module, you have two options:

  1. Follow Android Studio’s dynamic feature module creation wizard.
  2. Refactor a normal com.android.library module into a dynamic feature module.

In this case, you’ll use the second option. Not only is it a lot more interesting, but it’ll help you learn more, too.

To use this option, you’ll need to make changes in both the app and sharing modules.

Preparing the app module

When using app bundles, you install the Gradle module defined as a com.android.application first, so it makes sense to start from there. Typically, this is the app module.

Note: Although PetSave doesn’t need it, some apps require that you add some specific configuration to your app module’s AndroidManifest.xml to support dynamic features. Find out how to do this at https://developer.android.com/guide/app-bundle/configure-base.

Start by opening the app module’s build.gradle. Locate the sharing module dependency and remove it:

implementation project(":features:animalsnearyou")
implementation project(":features:search")
implementation project(":features:onboarding")
implementation project(":features:sharing") // <- Remove
implementation project(":common")
implementation project(":logging")

Remember that dynamic feature modules depend on the base module, not the other way around. That said, add the following line at the bottom of the android tag, just below packagingOptions:

dynamicFeatures = [":features:sharing"]

No matter how many dynamic features you have, you only have to set up the app module once. As you add more dynamic features, however, you’ll need to let the app module know about them here.

Managing dependencies

Go back to the dependencies tag. Since dynamic features depend on the app module, it’s a common practice to serve some of the common dynamic features dependencies through app. To do so, start by changing:

implementation project(":common")
implementation project(":logging")

to:

api project(":common")
api project(":logging")

Do the same for these:

// Kotlin
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_android_version"

// Support Libraries and material
implementation "androidx.appcompat:appcompat:$appcompat_version"
implementation "com.google.android.material:material:$material_version"

// Navigation
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"

Changing them to:

// Kotlin
api "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_android_version"

// Support Libraries and material
api "androidx.appcompat:appcompat:$appcompat_version"
api "com.google.android.material:material:$material_version"

// Navigation
api "androidx.navigation:navigation-fragment-ktx:$nav_version"
api "androidx.navigation:navigation-ui-ktx:$nav_version"

Finally, below the last Navigation component dependency, add:

api "androidx.navigation:navigation-dynamic-features-fragment:$nav_version"

This last dependency gives you two things:

  1. The Dynamic Navigator dependency. You’ll use this later to navigate to the dynamic feature.
  2. The Play Core dependencies the app needs to support dynamic features.

Dynamic Navigator handles dynamic feature installation for you. If you wanted to do it manually without using Dynamic Navigator, you’d include the com.google.android.play:core$version dependency, instead.

Note: The Navigation component version used here is 2.3.1. If there’s a newer version available by the time you’re going through this project, be careful about installing it. Version 2.3.2 has a bug that messes up standard dynamic feature installation. So, update only if there’s a newer version than that available.

Sync Gradle to make sure everything is OK.

Defining module names

When your app requests a dynamic feature, you usually ask the user to confirm that they want to install it. For that, you need the module’s name. Since you might need the module’s name before the user downloads it, you should define it in the base module as a string resource of up to 50 characters.

When you have enough dynamic features and/or string resources, it makes sense to have a separate string resource file just for dynamic feature names.

Go to res in the app module and open strings.xml under values. Add the sharing module title as the only string resource there:

<string name="dynamic_feature_sharing_title">Share an animal</string>

Giving the app access to the dynamic features

Your last step is to enable access to dynamic feature code and resources on the app. To do this, enable SplitCompat.

You can enable SplitCompat in one of three ways:

  1. Declaring SplitCompatApplication as the Application subclass in the manifest, through the android:name property of the application tag. This won’t work, in this case, because PetSave uses a custom Application.
  2. Having your custom Application extend SplitCompatApplication.
  3. Having your custom Application override attachBaseContext(base: Context). That lets you avoid extending SplitCompatApplication, which performs the override for you internally.

In the app module, locate and open PetSaveApplication.kt. To enable SplitCompat, change the class to extend SplitCompatApplication instead of Application:

class PetSaveApplication: SplitCompatApplication()

If you don’t want to extend SplitCompatApplication, override attachBaseContext(), as mentioned above:

override fun attachBaseContext(base: Context) {
  super.attachBaseContext(base)

  SplitCompat.install(this)
}

Whichever one you prefer to use, the result is the same. On a side note, you have to override this method in all dynamic feature Activity instances. You do this by replacing SplitCompat.install(this) with SplitCompat.installActivity(this). Since PetSave only has one Activity, however, you don’t need to worry about it here.

Now you can try to build the app. You’ll get a compile-time error stating: Could not resolve project :features:sharing. because sharing isn’t a dynamic feature module yet.

Figure 10.3 — Gradle Sync Error
Figure 10.3 — Gradle Sync Error

This is a problem you need to fix.

Preparing the feature module

Now, it’s time to refactor the sharing module. Start by opening its AndroidManifest.xml.

First, define the distribution namespace as a property in the manifest tag:

xmlns:dist="http://schemas.android.com/apk/distribution"

Then, inside the manifest tag, add the following:

<dist:module // 1
  dist:instant="false" // 2
  dist:title="@string/dynamic_feature_sharing_title"> // 3
  <dist:delivery> // 4
    <dist:on-demand /> // 5
  </dist:delivery>
  <dist:fusing dist:include="true" /> // 6
</dist:module>

Note: If you copy the code above, you’ll have to remove the inline comments. XML doesn’t allow comments inside tags.

There’s quite a lot going on here:

  1. Open the dist:module tag. This is the main tag for dynamic feature configuration.
  2. Set the dist:instant property of dist:module to false. This means that the feature module won’t be available through Google Play Instant. If you set it to true, you’d have to set it in the base module’s manifest as well.
  3. Set the dist:title property of the dist:module tag. Here, you use the string resource you declared earlier in the app module.
  4. This is where the fun starts. This tag encapsulates all the information about how you deliver the feature module. You can only use one of these tags per feature.
  5. You want the app to request the feature when the user tries to access it. This tag makes it so that the feature isn’t available at install time, but is available for download later.
  6. Setting this to true will include the module in multi-APKs targeting devices with Android API 20 or lower. It seems redundant when PetSave’s minimal SDK level is 23, but you still need to set this tag.

Build the project now… and you’ll get the same error. While the manifest is ready, Gradle isn’t aware that this module represents a dynamic feature yet.

Notifying Gradle about the dynamic feature

Locate the feature.sharing module’s build.gradle. Open it and delete everything inside. Then, add these lines at the top of the file:

apply plugin: 'com.android.dynamic-feature'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'

The first plugin tells Gradle to handle the module as a dynamic feature. You should already be familiar with the others.

Below these, add the android block:

android {
  compileSdkVersion rootProject.ext.compileSdkVersion

  defaultConfig {
    minSdkVersion rootProject.ext.minSdkVersion
    targetSdkVersion rootProject.ext.targetSdkVersion
  }

  compileOptions {
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
  }

  kotlinOptions {
    jvmTarget = JavaVersion.VERSION_1_8.toString()
  }

  buildFeatures {
    viewBinding true
  }
}

It has just enough information for the code to compile and run. Things like app signing, code shrinking and app versioning should be handled by the app module.

Finally, add the dependencies block below:

dependencies {
  implementation project(':app')

  // Constraint Layout
  implementation "androidx.constraintlayout:constraintlayout:$constraint_layout_version"

  // UI
  implementation "com.github.bumptech.glide:glide:$glide_version"
  kapt "com.github.bumptech.glide:compiler:$glide_version"

  // DI
  implementation "com.google.dagger:hilt-android:$hilt_version"
  kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
}

That first implementation is the most important one. As you already know, all dynamic features depend on the app module. The remaining dependencies are pretty standard.

Sync Gradle and build the app. It fails, and Logcat tells you there was a manifest merger error. The app module is complaining because it can’t find a navigation XML file called nav_sharing.

Go to the app module’s res, expand navigation and open nav_graph.xml. You’ll see there’s an include for nav_sharing.

Figure 10.4 — Missing Navigation Definition
Figure 10.4 — Missing Navigation Definition

nav_sharing is the sharing module’s navigation graph. The include is in red, which tells you there’s an error. The app module doesn’t depend on the sharing module now, so it can’t reach its navigation graph.

Delete the line in red. Now, you can build and run without any problems… as long as you don’t click the Share button in the animal details screen.

Figure 10.5 — Don’t Click the Share Button Yet!
Figure 10.5 — Don’t Click the Share Button Yet!

If you do, the app will crash because it has no idea how to navigate to the module! You’ll fix that next.

Handling navigation

The Dynamic Navigator from the Navigation component library is just like the regular navigator. In fact, it’s an extension of the regular navigator, letting you navigate to dynamic feature modules just as you would to regular modules.

Before you can use it, the first change you have to make is in the app module. You need to replace any NavHostFragments in the app with DynamicNavHostFragments. You only have one NavHostFragment, so go to res and open activity_main.xml from the layout directory. Locate FragmentContainerView and change it to this:

<androidx.fragment.app.FragmentContainerView
  android:id="@+id/nav_host_fragment"
  android:name="androidx.navigation.dynamicfeatures.fragment.DynamicNavHostFragment"
  android:layout_width="match_parent"
  android:layout_height="0dp"
  android:layout_weight="1"
  app:defaultNavHost="true" />

Then, go to the module’s root and open the file MainActivity.kt in the main.presentation package. Locate the lazy delegate for navController and update the cast to match the change you just made:

private val navController by lazy {
  (supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as DynamicNavHostFragment)
    .navController
}

The app will build and run now, and navigation should work as expected… apart from the Share button crash, which you still need to fix.

Fixing the Share button

So far, you nested the nav_sharing graph into the nav_graph by including it there. Dynamic Navigator lets you do the same thing, but you need to use a different tag. You’ll include the sharing module to keep the code similar to how it was before. Note that Dynamic Navigator lets you navigate to a fragment tag, just as the normal navigator does.

In the app module, open res/navigation/nav_graph.xml and add this block of code below the include tags, but still inside the navigation tag:

<include-dynamic
  android:id="@+id/dynamicFeatureSharing"
  app:graphPackage="com.raywenderlich.android.petsave.sharing"
  app:graphResName="nav_sharing"
  app:moduleName="sharing" />

The include-dynamic tag works like the include tag, but for dynamic features. For it to work, you need to set a few important properties:

  • id: The dynamic navigator uses this ID instead of the ID in the root element of the included graph.
  • graphPackage: The root package of the dynamic feature.
  • graphResName: The name of the navigation graph to include.
  • moduleName: The feature module’s name.

If you were navigating to a fragment tag, you’d only need to add the app:moduleName, like this:

<fragment
  android:id="@+id/sharingFragment"
  android:name="com.raywenderlich.android.petsave.sharing.presentation.SharingFragment"
  app:moduleName="sharing" />

However, these dynamically included graphs don’t support deep links yet. Therefore, you’ll need to change things so you can navigate to sharing from the animal details screen.

Navigating between the animal details and sharing screens

First, you need to create the navigation action shown in the graph. Go to the animalsnearyou module and open nav_animalsnearyou.xml in res/navigation. In the fragment tag for AnimalDetailsFragment, below the argument tag already there, add this code:

<action
  android:id="@+id/action_details_to_sharing"
  app:destination="@id/dynamicFeatureSharing">

  <argument
    android:name="id"
    app:argType="long" />
</action>

This action lets you navigate to the dynamically included destination. The start destination of that graph, SharingFragment, needs the ID of the animal. Hence, the argument tag inside the action.

You’ll see a red squiggly line below the ID. Nevertheless, you can build the app and it will even run. To get rid of that squiggly line, you need to:

  1. Create the ID here by changing @id to @+id.
  2. Remove the plus (+) sign from the ID in the include-dynamic tag you added earlier.

Since app depends on animalsnearyou, this avoids any dependency error. It will work if you have the plus sign in both places, but you don’t have to recreate the same ID.

Running the navigation action

Now, your last step is to run the navigation action in the code. Open AnimalDetailsFragment.kt in the animalsnearyou.presentation.animaldetails package of animalsnearyou. Build the app to generate the navigation directions. Then, locate navigateToSharing() and delete the code inside.

In its place, add:

val animalId = requireArguments().getLong(ANIMAL_ID)

val directions = AnimalDetailsFragmentDirections.actionDetailsToSharing(animalId)

findNavController().navigate(directions)

If you’re used to the Navigation component, you won’t find anything unfamiliar here. It’s the same code you’d use to navigate to any other module.

Build and run. Try to access the sharing feature by clicking the Share button. The app crashes!

The error states that the included navigation ID, nav_sharing, is different from the destination ID, dynamicFeatureSharing. It also tells you to either remove the navigation ID or make the two IDs match.

Even if you make them match, it’ll still complain because they live in different namespaces. So go to the sharing module, open res/navigation/nav_sharing.xml and remove the ID from the navigation tag. While you’re at it, remove the deep link from the fragment tag as well.

Build and run again. Click the Share button in the animal details screen. Oops — another crash!

Remember how you can’t properly use Hilt with dynamic features? Well, here’s the proof. Look at the error and you’ll see that it failed when it tried to inject things into SharingFragment. You’ll have to make some changes to the way you’re injecting dependencies to fix this.

Handling dependency injection

Hilt doesn’t work well with dynamic features because of its monolithic component architecture.

Hilt creates a component for each type. For example, all Activity instances come from the same component, all Fragment instances from another component and so on. To handle this, Hilt needs to know about every binding at compile time. Since dynamic features are loaded dynamically, Hilt can’t directly access their bindings.

Hilt creates the dependency graph from the app module. That’s where the @HiltAndroidApp annotation is — it annotates PetSaveApplication. For that reason, the solution — at least for now — is to create the dependencies that dynamic features need in the app module.

At the root of the app module, next to the main package, create a new package called di. In it, create SharingModuleDependencies.kt, then inside, create an interface with the same name.

Annotate the interface:

@EntryPoint
@InstallIn(SingletonComponent::class)
interface SharingModuleDependencies

Notice that the first annotation is @EntryPoint and not @AndroidEntryPoint. The latter is for Android components. As for the @InstallIn, you have to do it in SingletonComponent.

You’ll access the dependencies through Application, so Hilt needs to install the dependencies in SingletonComponent for everything to work. Try using a different component and you’ll get an error.

Here’s a list of the dependencies that the sharing module needs:

  1. Any ViewModel instances and use cases.
  2. DispatchersProvider.
  3. AnimalRepository — which, in turn, needs Cache, Preferences, PetFinderApi and Context.

The sharing feature uses the GetAnimalDetails use case. This is why you defined the use case in the common module, instead of in animalsnearyou.

Use cases are regular classes, so their @Inject annotation will do the work for you. ViewModel instances are a beast of their own, so you’ll handle them with regular Dagger inside the sharing module.

Declaring dependencies

So, which dependencies should you handle here? Declare these operations in the interface:

fun petFinderApi(): PetFinderApi
fun cache(): Cache
fun preferences(): Preferences

There’s another caveat: You can only inject dependencies that you would normally install in the SingletonComponent through this interface. This means that you can’t inject AnimalRepository and DispatchersProvider here, since they’re installed in ActivityRetainedComponent.

Hilt will use these methods to find the right bindings in the dependency graph. For everything else, well… you have to use Dagger. You’ll also use it to inject Context the old-fashioned way.

Before using it, you need the dependencies. Go to sharing’s build.gradle, and add them next to the Hilt ones:

implementation "com.google.dagger:dagger:$dagger_version"
kapt "com.google.dagger:dagger-compiler:$dagger_version"

Also, remove apply plugin: 'dagger.hilt.android.plugin' at the top. Sync Gradle.

Bringing in Dagger

At the root of the sharing module, next to presentation, create a di package. Inside, create a file called SharingComponent.kt, with a SharingComponent interface inside.

Annotate the interface:

@Component(dependencies = [SharingModuleDependencies::class])
interface SharingComponent

This interface is a regular Dagger Component. Passing SharingModuleDependencies as its dependency lets you connect it to Hilt’s dependency graph.

Inside the interface, add the following code:

fun inject(fragment: SharingFragment) // 1

// 2
@Component.Builder
interface Builder {
  fun context(@BindsInstance context: Context): Builder
  fun moduleDependencies(sharingModuleDependencies: SharingModuleDependencies): Builder
  fun build(): SharingComponent
}

Having cold sweats yet? No need! Here’s what you’re seeing in the code above:

  1. The method you’ll use to inject dependencies into SharingFragment.
  2. Your trusty old Dagger component builder. You’ll inject the application Context through the builder, along with the dependencies that Hilt can provide.

After this, expand presentation and open SharingFragmentViewModel.kt. Change the class definition from:

@HiltViewModel
class SharingFragmentViewModel @Inject constructor

to:

class SharingFragmentViewModel @Inject constructor

You removed the dependency that gave you the annotation, so you’ll get a compile-time error if you don’t change this.

Build the app. You’ll get another error, but this one’s in SharingFragment. To fix it, open SharingFragment.kt and remove the @AndroidEntryPoint annotation at the top.

Build the app so Dagger generates Component, and it will work this time. Don’t run yet, because you still need to set up SharingFragment to inject the dependencies.

Preparing SharingFragment

In SharingFragment, above onCreateView(), override onCreate():

override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)
}

Below the super call, call the Component that Dagger generated and use it to inject the dependencies:

DaggerSharingComponent.builder()
  .context(requireActivity())
  .moduleDependencies(
      EntryPointAccessors.fromApplication(
          requireActivity().applicationContext,
          SharingModuleDependencies::class.java
      )
  )
  .build()
  .inject(this)

Everything is standard Dagger code except EntryPointAccessors.fromApplication. This Hilt method gives you access to the entry point of the app, which gives you access to the dependency graph.

Build and run. It’ll still crash if you try to open the sharing feature.

Up to this point, you relied on Hilt to build and inject SharingFragmentViewModel into SharingFragment. Now, however, the app has no idea how to handle the injection.

Using Dagger multibindings

To fix this, you’ll use Dagger multibindings to build a generic solution for ViewModel injection. In the di package you created just now, create ViewModelKey.kt. In it, add the following:

@MapKey
@Retention(AnnotationRetention.RUNTIME)
@Target(
    AnnotationTarget.FUNCTION,
    AnnotationTarget.PROPERTY_GETTER,
    AnnotationTarget.PROPERTY_SETTER
)
annotation class ViewModelKey(val value: KClass<out ViewModel>)

This annotation allows you to create a Key out of each ViewModel. You’ll use it to map the ViewModels themselves.

You also need a generic way to create ViewModel instances, so create ViewModelFactory.kt in the same package. In it, define the factory:

class ViewModelFactory @Inject constructor(
    private val viewModels: MutableMap<Class<out ViewModel>, Provider<ViewModel>>
) : ViewModelProvider.Factory {

  override fun <T : ViewModel> create(modelClass: Class<T>): T {
    var creator: Provider<out ViewModel>? = viewModels[modelClass]

    if (creator == null) {
      for ((key, value) in viewModels) {
        if (modelClass.isAssignableFrom(key)) {
          creator = value
          break
        }
      }
    }

    if (creator == null) {
      throw IllegalArgumentException("Unknown viewModel class $modelClass")
    }

    try {
      @Suppress("UNCHECKED_CAST")
      return creator.get() as T
    } catch (e: Exception) {
      throw RuntimeException(e)
    }
  }
}

This class takes a MutableMap of ViewModel instances and returns the correct instance type you’re trying to create. Dagger will inject the Map in this class.

Now, you defined a way to create a Key to the Map, but you haven’t specified how to create a Value yet. In other words, you’re not binding any ViewModel instances yet.

Binding the ViewModels

To bind the ViewModel instances, start by creating SharingModule.kt. In it, add SharingModule and annotate it with @Module:

@Module
abstract class SharingModule

In the abstract class, add these bindings:

// 1
@Binds
@IntoMap
@ViewModelKey(SharingFragmentViewModel::class) // 2
abstract fun bindSharingFragmentViewModel(
    sharingFragmentViewModel: SharingFragmentViewModel
): ViewModel

// 3
@Binds
@Reusable // 4
abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory

Here’s what’s happening above:

  1. The first binding method binds SharingFragmentViewModel using @Binds and @IntoMap. Now, Dagger knows that it should add this binding to the Map.
  2. You use the @ViewModelKey annotation and pass in the ViewModel. Dagger will set it as the Key for the Value of the Map, a SharingFragmentViewModel instance.
  3. You can’t create and inject the ViewModel instances on your own. For that, you need ViewModelFactory. The second binding method allows you to inject it.
  4. @Reusable is similar to @Singleton. It’ll make Dagger try to reuse the same ViewModelFactory instance, if available. It doesn’t ensure that the same instance lives throughout the whole app’s lifetime, though.

You now have to let SharingComponent know about SharingModule.

Notifying SharingComponent of SharingModule

Open SharingComponent.kt and refactor the @Component annotation to:

@Component(
    dependencies = [SharingModuleDependencies::class],
    modules = [SharingModule::class]
)
interface SharingComponent

Finally, you need to actually inject the ViewModel. Go to SharingFragment.kt. Below the companion object, inject the factory:

@Inject
lateinit var viewModelFactory: ViewModelFactory

Then, refactor the by viewModels delegate by passing in the factory:

private val viewModel by viewModels<SharingFragmentViewModel> { viewModelFactory }

That’s it! Android and Dagger will handle the rest.

Build the app. You’ll get an error in the SharingModule stating that the module’s missing an @InstallIn annotation. This happens because you’re still depending on Hilt, which checks every @Module for the @InstallIn annotation.

You could disable this, but it could come in handy if you really forget to add the annotation. Instead, you’ll let Hilt know it doesn’t have to check this module.

Fixing errors

To do this, go to SharingModule.kt and add this annotation below @Module:

@DisableInstallInCheck

This will tell Hilt to not check this specific Module.

Build the app again and it will fail and complain about not being able to find a DispatchersProvider binding. While it’s true that you can’t inject it through SharingModuleDependencies due to the reasons explained above, nothing’s stopping you from doing so through SharingModule.

Open SharingModule.kt and add the missing bindings:

@Binds
abstract fun bindDispatchersProvider(
    dispatchersProvider: CoroutineDispatchersProvider
): DispatchersProvider

@Binds
abstract fun bindRepository(
    repository: PetFinderAnimalRepository
): AnimalRepository

You now have bindings for these dependencies in two different places in the app. Not ideal, but it’s the best you can do with what you have available.

Build and run now. Once again, access the sharing feature — it’ll work!

Figure 10.6 — A Working Share Button!
Figure 10.6 — A Working Share Button!

However, you set the feature to be downloaded on demand. Why is it available right away? You’ll test the module install next.

Testing module install

Android Studio installs all your modules by default, including dynamic features. You can edit the run/debug configuration and choose not to install dynamic features right away. Unfortunately, if you use this method, they won’t install later, either. For instance, choosing not to install the sharing module triggers this screen:

Figure 10.7 — Dynamic Navigator Handles Everything for You, Even the Failure Screen
Figure 10.7 — Dynamic Navigator Handles Everything for You, Even the Failure Screen

To test the installation of dynamic feature modules, you have two options:

  1. Publish the app on Google Play, then use the internal test track.
  2. Use bundletool, a command-line tool.

Google Play’s internal test track is a great way to test your apps. Not only is it useful for larger-scale tests, but you can also see exactly how the dynamic feature code will behave in a real-life scenario.

However, as you might know, Google Play’s app review process takes quite a while — often, days. And you can’t test your app until Google Play reviews and accepts it!

So in this case, you’ll use bundletool. Download the latest version here: https://github.com/google/bundletool/releases.

Preparing to use bundletool

Before using it, you need to create an App Bundle. A debug one will do.

In Android Studio, go to Build ▸ Build Bundle(s) / APK(s) ▸ Build Bundle(s).

Figure 10.8 — Building a Debug App Bundle
Figure 10.8 — Building a Debug App Bundle

This process outputs app-debug.aab. Locate the file and move it to the directory that contains bundletool.jar, for convenience. Then, open a command-line window in that same directory and run the command:

java -jar bundletool.jar build-apks --local-testing --bundle app-debug.aab --output app-debug.apks --connected-device

Here, bundletool uses the app-debug.aab to create app-debug.apks, which contains all the split APKs you need to install the app.

--connected-device tells bundletool to produce app-debug.apks with only the split APKs needed for the connected device, whether that’s a real device or an emulator. It’s a cool way of testing App Bundles, by seeing which split APKs get installed.

--local-testing is what saves you from having to publish the app. It makes it possible for the Play Core library to use the split APKs to install dynamic features without connecting to the Play Store.

Now, to install app-debug.apks on a device, run this command:

java -jar bundletool.jar install-apks --apks app-debug.apks

Check the output to see which APKs bundletool installs. Using a Pixel 3 emulator with API 29, this was my output:

Pushed "/sdcard/Android/data/com.raywenderlich.android.petsave/files/local_testing/base-xxhdpi.apk"
Pushed "/sdcard/Android/data/com.raywenderlich.android.petsave/files/local_testing/base-master.apk"
Pushed "/sdcard/Android/data/com.raywenderlich.android.petsave/files/local_testing/base-en.apk"
Pushed "/sdcard/Android/data/com.raywenderlich.android.petsave/files/local_testing/sharing-xxhdpi.apk"
Pushed "/sdcard/Android/data/com.raywenderlich.android.petsave/files/local_testing/sharing-master.apk"

Open the app and try the feature again. You’ll see a screen with a progress bar like this one:

Figure 10.9 — Installing the Dynamic Feature
Figure 10.9 — Installing the Dynamic Feature

After a few seconds, you’ll see the sharing feature’s screen! As you can see, Dynamic Navigator handles everything for you. It shows the screen with a progress bar, triggers the feature download and handles any installation errors that occur.

Dynamic Navigator is also open for extension. It lets you have fine-grained control over things like reacting to different installation events yourself or even using your own progress bar screen. Pretty neat!

Key points

  • The app module doesn’t depend on dynamic feature modules. However, you still have to make it aware of them through the dynamicFeatures array in its Gradle configuration.
  • Navigation component’s Dynamic Navigator does all the heavy lifting of requesting and installing dynamic features for you. It also handles network errors and even provides a basic installation progress Fragment.
  • You can continue to use Hilt in your app when you have dynamic feature modules. Hilt currently provides some basic functionality to inject bindings into dynamic features, but Dagger does most of the work.
  • bundletool is a great way of testing dynamic feature installation without having to publish your app on Google Play’s internal test track.

This chapter concludes Section 2. In the next section, you’ll learn how to create and animate custom UI components. Have fun! :]

Where to go from here?

Great job on refactoring the module to a dynamic feature module. It took a lot of work, especially regarding Hilt/Dagger and navigation. Well done!

To learn more about App Bundles and Play Feature Delivery, check the official documentation at https://developer.android.com/guide/app-bundle.

If you’re interested in knowing more about the Play Core library, you can find its official documentation at https://developer.android.com/guide/playcore/play-feature-delivery.

For Dynamic Navigation, read the official documentation at https://developer.android.com/guide/navigation/navigation-dynamic and raywenderlich.com’s article about it at https://www.raywenderlich.com/7023243-navigation-and-dynamic-features.

Finally, the Android team released a series of videos as part of the MAD Skills series about App Bundles. You should check it out at https://youtu.be/hTC0rKllhIw.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.