Chapters

Hide chapters

Dagger by Tutorials

First Edition · Android 11 · Kotlin 1.4 · AS 4.1

18. Hilt & Architecture Components
Written by Massimo Carli

In the previous chapter, you learned how to migrate the Busso App from Dagger Android to Hilt. You saw the main Hilt architectural decisions and how to apply them using new APIs like @InstallIn and @AndroidEntryPoint.

In this chapter, you’ll learn more about Hilt, including how to:

  • Use Hilt with other supported Android standard components, like Services.
  • Create custom Hilt @Endpoints for currently unsupported Android standard components, like ContentProviders.
  • Use Hilt with ViewModels.

Throughout the chapter, you’ll work with the RayTrack app. Don’t worry about the amount of code the app has. It’s purposely a complex app, to give you the opportunity to work with Hilt’s architectural components. You’ll only focus on the things that have to do with Hilt. To save space, you’ll only see the most relevant parts of the code in this chapter.

Note: An interesting exercise would be to simplify RayTrack’s code. Check the project for the full code.

Note: This chapter requires knowledge of the main Android Architecture Components, like Lifecycle, ViewModel and LiveData.

The RayTrack app

In this chapter, you’ll work on RayTrack. Use Android Studio to open the RayTrack project in the starter folder for this chapter. You’ll see the project structure shown in Figure 18.1:

Figure 18.1 — Starting RayTrack project
Figure 18.1 — Starting RayTrack project

RayTrack has some modules in common with Busso:

  • libs.di.scope
  • libs.location.api
  • libs.ui.navigation

It also has some new modules:

  • libs.location.api-android
  • libs.location.flow

These are just a different implementation of the abstraction you have in libs.location.api for Android. They use Kotlin Flows and coroutines.

Build and run and you’ll get, after the splash screen and permission request, something like in Figure 18.2:

Figure 18.2 — RayTrack in Action
Figure 18.2 — RayTrack in Action

Yeah, the UI isn’t the best, but the RayTrack app does what you need. :] When you press the Start Tracking button, you start a Foreground Service that keeps track of your current location and stores it in a database. Pressing the Stop Tracking button while the tracking is running stops the service. While the service is running, you can also see the location in the notification area:

Figure 18.3 — RayTrack Notification
Figure 18.3 — RayTrack Notification

Selecting the notification area returns you to the list of locations shown in Figure 18.2. Finally, you can select the Clear Data button and delete the database content at any time.

Now that you know how RayTrack works, it’s time to see how it’s set up.

RayTrack’s architecture

RayTrack consists of three main parts:

  1. Foreground service: Tracks the location.
  2. RayTrackContentProvider: Provides location data persistence.
  3. MainActivity: Displays the location data on the screen.

Here’s a detailed description of each of these important parts.

Tracking location data in the foreground

RayTrack uses Service to track the user’s location. Because the user’s location is sensitive data, Android requires you to implement this as a foreground service so the user always knows when it’s running on their device.

This means that you have some specific constraints you must follow. For instance, you must display a Notification when Service is running. In Figure 18.4, you can see the architecture for this part of the RayTrack app:

Figure 18.4 — The TrackingService
Figure 18.4 — The TrackingService

In the diagram, there are some important things to note:

  1. TrackingService extends LifecycleService, which is a utility class Google provides for cases when you need your Service to be a LifecyclerOwner. You need this because TrackerStateManager exposes an interface based on LiveData that requires LifecyclerOwner to be observed.
  2. You start and stop TrackingService directly from MainActivity using the classic startService() and stopService().
  3. To display the notification that Android requires when TrackingService is running, you need a dependency on NotificationManager.
  4. An Android Service is an adapter between the Android environment and some thread that needs to live for a certain time. It’s the ideal place to start long-living threads. In this case, TrackingService starts and stops Tracker and observes TrackerStateManager to update the information in Notification.
  5. Tracker is the abstraction of the object responsible for the actual tracking of the location.

Looking at the diagram in Figure 18.4, you also understand which objects TrackingService depends on. It needs a reference to:

  • Tracker
  • TrackerStateManager

The good news is that you can use Service as an injection target for Hilt. To inject the required dependencies, you just need to do what’s already in TrackingService.

Injecting dependencies

Open TrackingService.kt in raytracker.service and look at the following code:

@ExperimentalCoroutinesApi
@AndroidEntryPoint // 1
class TrackingService : LifecycleService() {

  @Inject
  lateinit var tracker: Tracker // 2

  @Inject
  lateinit var trackerStateManager: TrackerStateManager // 2
  // ...
}

In that code, you used:

  1. @AndroidEntryPoint because Service is an Android Standard Component that Hilt supports.
  2. @Inject for the property containing the reference to the dependent objects.

To understand where those dependencies come from, just click on the icons in Figure 18.5:

Figure 18.5 — Binding sources
Figure 18.5 — Binding sources

In Tracker’s case, you’ll see the following code from TrackerModule.kt in di:

interface TrackerModule {

  @Module(
      includes = [FlowLocationModule::class]
  )
  @InstallIn(ServiceComponent::class) // HERE
  interface ServiceBindings {
    @Binds
    fun bindTracker(
        impl: TrackerImpl
    ): Tracker
  }
  // ...
}

This shows how to use @InstallIn to install the Tracker implementation in the dependency graph for the ServiceComponent.

Now, open TrackerImpl.kt in service and look at the following code:

@ServiceScoped // HERE
class TrackerImpl @Inject constructor(
    private val trackerStateManager: TrackerStateManager,
    private val locationFlow: @JvmSuppressWildcards Flow<LocationEvent>
) : Tracker, CoroutineScope {
  // ...
}

This is an example of @ServiceScoped as the @Scope for an object that lives as long as a Service.

Select the icon next to TrackerStateManager and you end up, once again, in TrackerModule.kt in di, where you have the following code:

interface TrackerModule {
  // ...
  @Module
  @InstallIn(ApplicationComponent::class) // HERE
  interface ApplicationBindings {
    @Binds
    fun bindTrackerStateManager(
        impl: TrackerStateManagerImpl
    ): TrackerStateManager
  }
}

In this case, TrackerStateManager needs to be in ApplicationComponent because it will contain the current state of the tracking. Look at TrackerStateManagerImpl.kt in state and see the following code:

@ApplicationScoped // HERE
class TrackerStateManagerImpl @Inject constructor(
    @ApplicationContext private val context: Context
) : TrackerStateManager {
  // ...
}

Here you just use @ApplicationScoped, which is the same @AliasOf for @Singleton that you used in the previous chapters.

TrackingService is an example of how to use Hilt with a Service. But what about a standard component that isn’t supported yet? You’ll see how to do that next.

Persisting location data

In the previous paragraph, you saw how to inject dependencies in TrackingService. You discovered that the dependency injection in a Service with Hilt is no different from the injection in an Activity, a Fragment or any other component Hilt supports.

You still use @AndroidEntryPoint to tag the Service as an injection target and @Inject to assign values to local properties. Service is an Android standard component that Hilt supports — but you’re not always so lucky. At the moment, for example, Hilt doesn’t support ContentProviders. However, Hilt gives you some tools to fix the problem.

To see how everything works you need to:

  1. Understand ContentProvider’s role in RayTrack.
  2. Enable the dependency injection in ContentProvider using @EntryPoint.

In the first case, a good UML diagram will help.

Understanding the role of ContentProvider in RayTrack

RayTrack uses a ContentProvider to persist location data. To understand how this works, look at the diagram in Figure 18.6:

Figure 18.6 — RayTrack persistence
Figure 18.6 — RayTrack persistence

This diagram explains some interesting points:

  1. TrackStateManager is the abstraction of the object responsible for maintaining Tracker’s state. This is the one that tells you if Tracker is running and what the user’s current location is.
  2. TrackerStateManagerImpl is the TrackStateManager implementation. Every time it receives a new TrackState, it delegates the persistence of the related TrackData to a TrackDataHelper.
  3. TrackDataHelper is the abstraction of the object responsible for TrackData’s persistence.
  4. TrackDataHelperImpl is the TrackDataHelper implementation that uses ContentResolver to persist the data into a ContentProvider in RayTrackContentProvider.
  5. RayTrackContentProvider delegates the persistence operation to a TrackDao you define using the architecture component Room.

Open RayTrackContentProvider.kt in repository.contentprovider and notice how you only need to manage the dependency on the Room database:

class RayTrackContentProvider : ContentProvider(), CoroutineScope {
  // ...
  private lateinit var trackDatabase: TrackDatabase
  private lateinit var trackDao: TrackDao

  override fun onCreate(): Boolean {
    trackDatabase = getRoomDatabase()
    trackDao = trackDatabase.trackDao()
    return true
  }

  private fun getRoomDatabase(): TrackDatabase = // HERE
      Room.databaseBuilder(
          context!!,
          TrackDatabase::class.java,
          Config.DB.DB_NAME
      ).fallbackToDestructiveMigration().build()
  // ...
}

At the moment, the relationship is a composition because you create the TrackDatabase instance in RayTrackContentProvider directly.

It would be nice to remove getRoomDatabase() and inject TrackDatabase directly using Hilt. You’ll see how to do that next.

Creating a custom @EntryPoint

As you learned, Hilt doesn’t support ContentProviders as a predefined @AndroidEntryPoint — but it gives you tools to work around that. That’s what you’ll do for RayTrackContentProvider.

Note: In this case, you just need to inject TrackDatabase. This is a component that lives as long as the app, so it’s in ApplicationComponent.

In this case, you need to:

  1. Add the binding for TrackDatabase in the proper @Component.
  2. Define an @EntryPoint that declares TrackDatabase as an object you can access from an unsupported component.
  3. Access and use the object @EntryPoint exports in RayTrackContentProvider.

Adding bindings

To start, open TrackDBModule.kt in di and add:

@Module
@InstallIn(ApplicationComponent::class)
object TrackDBModule {

  @Provides
  fun provideTrackDatabase( // HERE
      @ApplicationContext context: Context
  ): TrackDatabase =
      Room.databaseBuilder(
          context,
          TrackDatabase::class.java,
          Config.DB.DB_NAME
      ).build()
  // ...
}

Here, the TrackDBModule installs bindings into ApplicationComponent. You’re just providing TrackDatabase by using code similar to the one you had in RayTrackContentProvider.

Now, TrackDatabase is in the dependency graph for the app. You need to use it from RayTrackContentProvider.

Defining an entry point

Next, open RayTrackContentProvider.kt in repository.contentprovider and add the following code:

class RayTrackContentProvider : ContentProvider(), CoroutineScope {

  @EntryPoint // 1
  @InstallIn(ApplicationComponent::class) // 2
  interface ContentProviderEntryPoint {

    fun trackDatabase(): TrackDatabase // 3
  }
  // ...
}

This is the definition of the ContentProviderEntryPoint interface that declares what RayTrackContentProvider needs from RayTrack’s existing dependency graph. In this code, you used:

  1. @EntryPoint to tell Hilt, and then Dagger, that this is the interface you want your custom component to use to access objects in the dependency graph.
  2. @InstallIn(ApplicationComponent::class) to make the @EntryPoint Hilt creates for you as part of ApplicationComponent.
  3. trackDatabase() to tell Hilt, and then Dagger, that the object you need is TrackDatabase.

Accessing TrackDatabase

Finally, you need to access TrackDatabase in RayTrackContentProvider. In RayTrackContentProvider.kt in repository.contentprovider, replace the existing getRoomDatabase() with:

  private fun getRoomDatabase(): TrackDatabase {
    val appContext = context?.applicationContext ?: throw IllegalStateException() // 1
    val hiltEntryPoint =
        EntryPointAccessors.fromApplication( // 2
            appContext,
            ContentProviderEntryPoint::class.java) // 3
    return hiltEntryPoint.trackDatabase() // 4
  }

In this code, you:

  1. Access ApplicationContext, throwing an exception if Context isn’t available.
  2. Use fromApplication() static function of EntryPointAccessors to access the reference to the @EntryPoint you defined in the same class.
  3. Need to provide the class for ContentProviderEntryPoint to get an object of the right type.
  4. Use ContentProviderEntryPoint to access the TrackDatabase reference.

It’s important to note that EntryPointAccessors is a utility class that Hilt provides for cases where the bindings you need are in @Components that Hilt already supports. Other methods are:

  • EntryPointAccessors.fromActivity()
  • EntryPointAccessors.fromFragment()
  • EntryPointAccessors.fromView()

In your case, you used EntryPointAccessors.fromApplication() because the binding for TrackDatabase is in ApplicationComponent.

Note: To be picky, what you just did is not actually dependency injection. You still depend on the EntryPointAccessors and the injection doesn’t come from outside. This is reminiscent of what happens with the service locator pattern.

It’s important to say that, in ContentProvider’s case, you access a TrackDatabase object that has a binding in ApplicationComponent. As you’ll see later, Hilt gives you the tools to create a custom @Component with a custom lifecycle. To do this properly, you need control over the creation and destruction of the custom @Component instance. This is something you don’t have for ContentProvider. This isn’t a problem, however, because you can assume that its lifecycle is the same as ApplicationComponent’s.

Great job! You managed to create a custom @EntryPoint for a ContentProvider you implemented in RayTrackContentProvider. Build and run and check that everything works as expected.

It’s now time to see how TrackData displays onscreen.

Displaying the location data

RayTrack can display the location data on the screen. The diagram in Figure 18.7 describes the current architecture:

Figure 18.7 — Using CurrentLocationViewModel in MainActivity
Figure 18.7 — Using CurrentLocationViewModel in MainActivity

In this diagram, you see some interesting points:

  1. MainActivity depends on TrackerStateManager, TrackDataHelperImpl and CurrentLocationViewModel.
  2. CurrentLocationViewModel depends on TrackDataHelper and TrackerStateManager.

The code in MainActivity.kt in ui.main shows something that the diagram already highlights:

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

  @Inject
  lateinit var trackerStateManager: TrackerStateManager // 2

  lateinit var locationViewModel: CurrentLocationViewModel

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    locationViewModel = CurrentLocationViewModel( // 1
        this.application,
        trackerStateManager,
        TrackDataHelperImpl(this) // 3
    )
    createNotificationChannel()
    startStopButton = findViewById(R.id.startStopTrackingButton)
    trackDataRecyclerView = findViewById(R.id.location_recyclerview)
    initRecyclerView(trackDataRecyclerView)
    handleButtonState(locationViewModel.locationEvents().value)
    handleTrackDataList(locationViewModel.storedLocations().value)
  }
  // ...

}

Here, MainActivity:

  1. Creates the instance of CurrentLocationViewModel. As you learned in the first chapter of this book, this is a composition relationship, which you know isn’t the best.
  2. Depends on TrackerStateManager because CurrentLocationViewModel constructor needs it.
  3. Creates an instance of TrackDataHelperImpl as the implementation of TrackDataHelper to pass to CurrentLocationViewModel.

There’s definitely room for improvement here.

Your goal now is to inject CurrentLocationViewModel into MainActivity, as you do with other components. Hilt provides a new library to accomplish this. You just need to:

  1. Add the dependency to the Hilt ViewModel support library.
  2. Annotate your ViewModel implementation with @ViewModelInject.
  3. Inject your ViewModel using ViewModelProvider or the viewModels() extension.

Now, you can migrate and replace the composition relationship with a simple dependency.

Adding VieModel support for Hilt

To add ViewModel support for Hilt, you need to extend the annotation processor. Open build.gradle for app and add the following dependencies:

// ...
dependencies {
  // ...
  // ViewModel Hilt support
  implementation "androidx.hilt:hilt-lifecycle-viewmodel:$viewmodel_hilt_version" // 1
  kapt "androidx.hilt:hilt-compiler:$viewmodel_hilt_version" // 2
  // ...
}

Note: viewmodel_hilt_version is already defined in versions.gradle. The current value is 1.0.0-alpha02 but you can update it if you need to.

Here, you just add the:

  1. Dependency to the hilt-lifecycle-viewmodel library.
  2. Extension to the annotation processor.

Now, sync Gradle for your project and you’re ready to use @ViewModelInject in your code.

Using @ViewModelInject in your ViewModel implementation

Open CurrentLocationViewModel.kt in ui.main and look at the following header:

@ExperimentalCoroutinesApi
class CurrentLocationViewModel(
    application: Application,
    private val trackerStateManager: TrackerStateManager,
    private val trackDataHelper: TrackDataHelper
) : AndroidViewModel(application) {
  // ...
}

This defines all the CurrentLocationViewModel dependencies. Now, change it to the following:

@ExperimentalCoroutinesApi
class CurrentLocationViewModel @ViewModelInject constructor( // HERE
    application: Application,
    private val trackerStateManager: TrackerStateManager,
    private val trackDataHelper: TrackDataHelper
) : AndroidViewModel(application) {
  // ...
}

Here, you just added @ViewModelInject to the primary constructor. In your case, all the parameters are already in the dependency graph for the app so you won’t get any complaints when you build.

Injecting your ViewModel implementation in MainActivity

For your final step, you need to improve MainActivity. Open MainActivity.kt in ui.main and replace it with:

@ExperimentalCoroutinesApi
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

  //@Inject // REMOVE  1
  //lateinit var trackerStateManager: TrackerStateManager

  val locationViewModel: CurrentLocationViewModel by viewModels() // 2

  private lateinit var trackListAdapter: TrackListAdapter
  private lateinit var trackDataRecyclerView: RecyclerView
  private lateinit var startStopButton: Button

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    /* // REMOVE 2
    locationViewModel = CurrentLocationViewModel(
        this.application,
        trackerStateManager,
        TrackDataHelperImpl(this) // 2
    )
     */
    createNotificationChannel()
    startStopButton = findViewById(R.id.startStopTrackingButton)
    trackDataRecyclerView = findViewById(R.id.location_recyclerview)
    initRecyclerView(trackDataRecyclerView)
    handleButtonState(locationViewModel.locationEvents().value)
    handleTrackDataList(locationViewModel.storedLocations().value)
  }

   // ...
}

Here, you:

  1. Removed the trackerStateManager injection because you don’t need it anymore. Hilt and Dagger will inject it in CurrentLocationViewModel for you.
  2. Deleted the code to create CurrentLocationViewModel. This includes the creation of TrackDataHelperImpl, which Hilt and Dagger will also inject in CurrentLocationViewModel.
  3. Used Kotlin delegation with viewModels(). It’s smart enough to understand what locationViewModel’s type is, so it knows which ViewModel to inject.

Now, build and run to check that everything works. The diagram in Figure 18.8 gives you a useful description of what you just did:

Figure 18.8 — Injecting CurrentLocationViewModel in MainActivity
Figure 18.8 — Injecting CurrentLocationViewModel in MainActivity

As you can see, this is much better because:

  1. MainActivity only depends on CurrentLocationViewModel.
  2. CurrentLocationViewModel depends on the TrackerStateManager and TrackDataHelper abstractions.

Great job! You just removed all the composition dependencies and replaced them with loosely coupled dependencies.

Creating a custom @Component with @DefineComponent

Earlier, you learned that Hilt doesn’t support all the Android standard components as @AndroidEntryPoints. That’s why you had to use @EntryPoint with RayTrackContentProvider. In that case, you wanted to inject TrackDatabase, which is an object in ApplicationComponent, with @Singleton — or your alias, @ApplicationContext — scope.

However, Hilt provides the APIs to create your own @Component with a specific @Scope and its own lifecycle. It’s worth mentioning that this is not something you should do very often because the existing @Components already cover most of the use cases.

A possible example is the logged state for a user in an app. You might have objects that need to be there only when the user is logged in to the app, and which you should remove if the user isn’t logged in.

In RayTrack’s case, you’ll define a new @Component for objects that need to exist only when the Tracker is running and there’s something to display. This means that the @Component should exist only when there’s an Activity to display locations from a running Tracker.

The steps you need to follow are:

  1. Create a custom @Scope.
  2. Create a custom @Component using @DefineComponent.
  3. Add a @DefineComponent.Builder.
  4. Manage the lifecycle for the @DefineComponent.
  5. Add bindings to the custom @Component with @EntryPoint.
  6. Use the custom @Component in your code.

It’s time to dive in.

Creating a custom @Scope

Each @Component Hilt supports has a specific @Scope, so the custom @Component you’ll create needs one as well. You already know how to do this. Just create a new package named custom in di and, inside, add a new file named TrackRunningScoped.kt with the following code:

@Scope
@MustBeDocumented
@Retention(AnnotationRetention.RUNTIME)
annotation class TrackRunningScoped

This is nothing different from what you did for other custom @Scopes in the previous chapters.

Creating a custom @Component using @DefineComponent

The next step is to create the custom @Component. In the same di.custom package, create a new file named TrackRunningComponent.kt and add the following code:

@DefineComponent(parent = ActivityComponent::class) // 1
@TrackRunningScoped // 2
interface TrackRunningComponent

In these few lines, there are some important things to note. Here, you use:

  1. @DefineComponent to add a new @Component to the ones Hilt supports. Its parent attribute is fundamental because it allows you to choose where in the existing hierarchy to add your @Component. In this case, you’re adding TrackRunningComponent as a child of ActivityComponent. You’re extending the @Component hierarchy, as shown in Figure 18.9.
  2. @TrackRunningScoped as the @Scope that binds objects to the lifecycle of TrackRunningComponent.

Extending the hierachy:

Figure 18.9 — Custom Component Hierarchy
Figure 18.9 — Custom Component Hierarchy

Now, because you’re responsible for creating and destroying the TrackRunningComponent implementation, Hilt requires you to provide a Builder for it.

Adding a @DefineComponent.Builder

Hilt requires you to manage the lifecycle of the custom @Component and wants you to provide a Builder to use to create the @Component instance. To do this, open TrackRunningComponent.kt and add the following code:

@DefineComponent(parent = ActivityComponent::class)
@TrackRunningScoped
interface TrackRunningComponent {

  @DefineComponent.Builder // 1
  interface Builder {
    fun sessionId(@BindsInstance sessionId: Long): Builder // 2
    fun build(): TrackRunningComponent // 3
  }
}

In this code, you:

  1. Use @DefineComponent.Builder to define the abstraction of the Builder you’ll use to create the specific TrackRunningComponent instance.
  2. Provide an object that will be part of the @TrackRunningComponent dependency graph. In this case, it’s a simple Long representing the concept of session. This is just an example. In your @DefineComponent.Builder, you might provide more objects, or none at all.
  3. Define a build() that must have TrackRunningComponent as the return type. It’s similar to @Component.Builder and @Subcomponent.Builder, which you learned about in previous chapters.

Now, you’ve created the TrackRunningComponent custom @Component with its own @TrackRunningScoped. For your next step, you need a way to manage its lifecycle.

Managing @DefineComponent’s lifecycle

What differentiates each @Component from the others is its lifecycle. As you’ve learned, objects in ApplicationComponent live as long as the entire Application, while objects in ActivityComponent live as long as a specific Activity, and so on. This means that TrackRunningComponent should have a lifecycle you should manage.

In this case, you want to create TrackRunningComponent when Tracker is running and destroy it when it isn’t running. To do this, you need:

  1. An object that knows when to create and destroy the instance of TrackRunningComponent. You’ll call this object TrackRunningComponentManager.
  2. A @Component with a lifecycle longer than TrackRunningComponent that contains the TrackRunningComponentManager and uses it to create and destroy the custom @Component.

To do this, create a new file named TrackRunningComponentManager.kt in di.custom and add the following code:

@ActivityScoped // 1
class TrackRunningComponentManager @Inject constructor(
    private val trackRunnningBuilder: TrackRunningComponent.Builder // 2
) {

  var trackRunningComponent: TrackRunningComponent? = null // 3

  fun startWith(sessionId: Long) { // 4
    if (trackRunningComponent == null) {
      trackRunningComponent = trackRunnningBuilder
          .sessionId(sessionId)
          .build()
    }
  }

  fun stop() {
    if (trackRunningComponent != null) {
      trackRunningComponent = null // 5
    }
  }
}

This code is very simple. In it, you:

  1. Use @ActivityScoped to bind the lifecycle of TrackRunningComponentManager to Activity’s lifecycle. This needs to be the one you specify as parent in the TrackRunningComponent definition.
  2. Inject the reference to TrackRunningComponent.Builder.
  3. Define trackRunningComponent, which will contain the reference to the current TrackRunningComponent implementation instance.
  4. Provide startWith() when you use TrackRunningComponent.Builder , which creates the TrackRunningComponent instance to store in trackRunningComponent.
  5. Provide stop() to reset TrackRunningComponent.

You also add checks to avoid creating unnecessary TrackRunningComponent instances if you invoke startWith() multiple times.

Now, you need a place to put TrackRunningComponentManager and bind it to TrackerState. You’ll use MainActivity for this. Open MainActivity.kt in ui.main and change it to:

@ExperimentalCoroutinesApi
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
  // ...
  @Inject
  lateinit var trackRunningComponentManager: TrackRunningComponentManager // 1

  private fun handleButtonState(newState: TrackerState?) {
    with(startStopButton) {
      if (newState is TrackerRunning) {
        trackRunningComponentManager.startWith(System.currentTimeMillis()) // 2
        text = getString(R.string.stop_tracking)
        setOnClickListener {
          stopService(Intent(this@MainActivity, TrackingService::class.java))
        }
      } else {
        trackRunningComponentManager.stop() // 3
        text = getString(R.string.start_tracking)
        setOnClickListener {
          startService(Intent(this@MainActivity, TrackingService::class.java))
        }
      }
    }
  }

  override fun onStop() {
    super.onStop()
    trackRunningComponentManager.stop() // 3
  }
  // ...
}

You want to create TrackRunningComponent only when Track is running and an Activity wants to display some data. Because of this, you:

  1. Inject TrackRunningComponentManager in trackRunningComponentManager.
  2. When Tracker is running, you invoke startWith() on trackRunningComponentManager. You use System.currentTimeMillis() as the value for the sessionId.
  3. When Tracker isn’t running, you invoke stop() on trackRunningComponentManager.

Now, TrackRunningComponent is there only when you actually need it and removed when you don’t. You created a place with a specific lifecycle where you might want to put objects you’ll use only when Track is running and there’s an Activity to display the locations.

Coming up, you’ll see an example of how to use it.

Adding bindings to the custom @Component with @EntryPoint

For an example of a TrackRunningScoped object, think of a simple Logger.

Note: The goal here is to show how bindings in custom @Components work. The specific object doesn’t really matter.

Create a new package named logging and create a new file, HiltLogger.kt, with the following code:

@TrackRunningScoped // 1
class HiltLogger @Inject constructor() {
  fun log(message: String) {
    Log.d("HILT_LOGGING", "$this -> $message") // 2
  }
}

This class:

  1. Has @TrackRunningScoped scope.
  2. Prints log messages with information about the instance.

Now, create a new file named HiltLoggerEntryPoint.kt in the same package with the following code:

@EntryPoint
@InstallIn(TrackRunningComponent::class) // HERE
interface HiltLoggerEntryPoint {

  fun logger(): HiltLogger
}

You already know how this code works. In this case, the only difference from the one you implemented above is that you’re installing the binding into TrackRunningComponent. This means you can’t use EntryPointAccessors because TrackRunningComponent isn’t a @Component for an Android standard component.

Don’t worry, Hilt provides an API for this. You’ll see how to use it next.

Using the custom @Component in your code

Your final step is to use HiltLogger in MainActivity. Open MainActivity.kt in ui.main and apply the following change:

@ExperimentalCoroutinesApi
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
  // ...
  private fun handleButtonState(newState: TrackerState?) {
    with(startStopButton) {
      if (newState is TrackerRunning) {
        with(trackRunningComponentManager) {
          startWith(System.currentTimeMillis())
          with(newState.location) {
            EntryPoints.get( // HERE
                trackRunningComponent, HiltLoggerEntryPoint::class.java
            ).logger().log("Lat: $latitude Long: $longitude")
          }
        }
        text = getString(R.string.stop_tracking)
        setOnClickListener {
          stopService(Intent(this@MainActivity, TrackingService::class.java))
        }
      } else {
        // ...
      }
    }
  }
  // ...
}

Here, Hilt provides EntryPoints with a get() that has TrackRunningComponent as its first parameter and @EntryPoint’s type as its second parameter. This allows you to retrieve the reference to HiltLogger from the dependency graph for the @Component it belongs to — TrackRunningComponent.

Build and run now and check that everything works as expected.

To check the lifecycle for TrackRunningComponent, run the following steps:

  1. Build and run.
  2. Start the tracking.
  3. Stop the tracking after a few seconds.
  4. Start the tracking again.
  5. Stop the tracking again after a few seconds.
  6. Check Logcat and filter it using HILT_LOGGING.

You’ll have something like this:

D/HILT_LOGGING: com...HiltLogger@32c174b -> Lat: 41.96721 Long: -94.39422 // FIRST
D/HILT_LOGGING: com...HiltLogger@32c174b -> Lat: 41.96721 Long: -94.39422
D/HILT_LOGGING: com...HiltLogger@32c174b -> Lat: 41.96721 Long: -94.39422
D/HILT_LOGGING: com...HiltLogger@32c174b -> Lat: 41.96721 Long: -94.39422
D/HILT_LOGGING: com...HiltLogger@2c5fa3a -> Lat: 41.96721 Long: -94.39422 // SECOND
D/HILT_LOGGING: com...HiltLogger@2c5fa3a -> Lat: 41.96721 Long: -94.39422
D/HILT_LOGGING: com...HiltLogger@2c5fa3a -> Lat: 41.96721 Long: -94.39422

In this case, the HiltLogger instance during the first tracking was @32c174b. During the second, it was @2c5fa3a. That means that a new instance of HiltLogger was created at every session, as expected.

Key points

  • Hilt currently doesn’t support all the Standard Android Components as @AndroidEntryPoints.
  • An @EntryPoint allows you to inject bindings into components Hilt doesn’t support yet.
  • You can create a custom @Component using @DefineComponent.
  • @DefineComponent needs a parent that allows you to extend the default Hilt @Component hierarchy.
  • Hilt provides a library to help you use dependency injection with ViewModel.

Congrats! In this chapter, you used some advanced Hilt APIs. You learned how to create custom @Components that extend the existing Hilt @Component hierarchy. You also learned how to use the libraries Hilt provides to manage dependency injection with ViewModel.

But Hilt can do even more. In the next chapter, you’ll learn everything you need to know about testing. See you there!

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.