Skip to main content

Dependency Injection with Hilt

This document covers the Hilt DI setup in the Visla GPS Android app: module organization, scoping strategies, build-type variants, and testing patterns.

Architecture Overview​

The app uses Hilt for dependency injection. Every module installs into SingletonComponent, giving app-wide singleton scope. The dependency graph flows through clean architecture layers:

Domain (interfaces) ← Data (implementations) ← DI (modules) β†’ Presentation (ViewModels)

Entry Point​

The @HiltAndroidApp annotation on the application class triggers Hilt's code generation:

@HiltAndroidApp
class VislaGPSApp : Application() {
@Inject
lateinit var notificationStateRepository: NotificationStateRepository

override fun onCreate() {
super.onCreate()
LocaleManager.getInstance(this).initializeLocale()
// ...
}
}

Module Inventory​

ModuleTypePatternPurpose
AppModuleobject@ProvidesApplication-level utilities (LocaleManager)
NetworkModuleobject@ProvidesOkHttpClient, Retrofit, all API interfaces
RepositoryModuleabstract class@BindsBinds 11 repository interfaces to implementations
ServiceModuleabstract class@BindsBinds push token manager
TokenManagerModuleobject@ProvidesEncrypted token storage and TokenManager
BaseUrlModuleobject@ProvidesProduction base URL provider
AppPreferencesModuleobject@ProvidesDataStore-backed app preferences
BillingModuleβ€”Qualifiers onlyDefines @RealBilling / @FakeBilling qualifiers
DebugBillingModuleobject@ProvidesFake billing manager (debug builds)
ReleaseBillingModuleobject@ProvidesReal Google Play billing (release builds)
TestBaseUrlModuleobject@TestInstallInReplaces base URL for instrumented tests
TestTokenModuleobject@TestInstallInReplaces token manager for instrumented tests

Repository Binding Pattern​

Repositories follow a consistent pattern: interface in domain β†’ implementation in data β†’ binding in RepositoryModule.

1. Define the Interface (Domain Layer)​

// domain/repositories/DeviceRepository.kt
interface DeviceRepository {
val devicesWithPositionsFlow: StateFlow<List<DeviceWithPosition>>
suspend fun getDevices(): List<Device>
suspend fun getDevice(id: Int): Device
suspend fun getDevicesWithPositions(): List<DeviceWithPosition>
suspend fun refreshDevices(): List<DeviceWithPosition>
suspend fun claimDevice(token: String): Device
suspend fun updateDevice(id: Int, name: String? = null, icon: String? = null, color: String? = null, category: String? = null): Device
suspend fun unclaimDevice(id: Int)
suspend fun getDeviceIcons(lang: String = "it"): List<DeviceIconCategoryDto>
suspend fun invalidateCache()
suspend fun muteDevice(id: Int): Boolean
suspend fun unmuteDevice(id: Int): Boolean
}

2. Implement It (Data Layer)​

// data/repositories/DeviceRepositoryImpl.kt
@Singleton
class DeviceRepositoryImpl @Inject constructor(
private val deviceApi: DeviceApi,
private val positionApi: PositionApi,
private val deviceMapper: DeviceMapper,
private val positionMapper: PositionMapper,
private val deviceDataStore: DeviceDataStore
) : DeviceRepository {
override val devicesWithPositionsFlow: StateFlow<List<DeviceWithPosition>>
get() = deviceDataStore.devicesWithPositions

override suspend fun getDevices(): List<Device> { /* ... */ }
// ...
}

The implementation uses @Inject constructor so Hilt knows how to create it. All constructor parameters are themselves provided by other modules.

3. Bind It (DI Layer)​

// di/RepositoryModule.kt
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindDeviceRepository(impl: DeviceRepositoryImpl): DeviceRepository

// ... 10 more bindings
}

4. Inject It (Presentation Layer)​

@HiltViewModel
class DeviceDetailViewModel @Inject constructor(
private val deviceRepository: DeviceRepository,
private val positionRepository: PositionRepository,
private val commandRepository: CommandRepository,
// ...
) : IntentViewModel<DeviceDetailIntent>()

ViewModels request the interface, never the implementation. Hilt resolves the binding at compile time.

Complete Binding List​

RepositoryModule binds all repositories except BillingRepository (handled by build-type modules):

@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds @Singleton
abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository

@Binds @Singleton
abstract fun bindDeviceRepository(impl: DeviceRepositoryImpl): DeviceRepository

@Binds @Singleton
abstract fun bindDeviceLifecycleRepository(impl: DeviceLifecycleRepositoryImpl): DeviceLifecycleRepository

@Binds @Singleton
abstract fun bindPositionRepository(impl: PositionRepositoryImpl): PositionRepository

@Binds @Singleton
abstract fun bindEventRepository(impl: EventRepositoryImpl): EventRepository

@Binds @Singleton
abstract fun bindGeofenceRepository(impl: GeofenceRepositoryImpl): GeofenceRepository

@Binds @Singleton
abstract fun bindSharingRepository(impl: SharingRepositoryImpl): SharingRepository

@Binds @Singleton
abstract fun bindSubscriptionRepository(impl: SubscriptionRepositoryImpl): SubscriptionRepository

@Binds @Singleton
abstract fun bindCommandRepository(impl: CommandRepositoryImpl): CommandRepository

@Binds @Singleton
abstract fun bindNotificationRepository(impl: NotificationRepositoryImpl): NotificationRepository

@Binds @Singleton
abstract fun bindRealTimeRepository(impl: RealTimeRepositoryImpl): RealTimeRepository
}

Network Module​

NetworkModule is the largest module. It provides the full HTTP stack and all Retrofit API interfaces.

Dual OkHttpClient Pattern​

The module creates two OkHttpClient instances to avoid a circular dependency during token refresh:

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

@Provides
@Singleton
fun provideLoggingInterceptor(): HttpLoggingInterceptor =
HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }

// Primary client β€” includes auth interceptor + token authenticator
@Provides
@Singleton
fun provideOkHttpClient(
authInterceptor: AuthInterceptor,
tokenAuthenticator: TokenAuthenticator,
loggingInterceptor: HttpLoggingInterceptor
): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
.authenticator(tokenAuthenticator)
.connectTimeout(NetworkConstants.DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(NetworkConstants.DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.writeTimeout(NetworkConstants.DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()

// Auth-free client β€” used only for token refresh to prevent deadlocks
@Provides
@Singleton
@NoAuthClient
fun provideNoAuthOkHttpClient(
loggingInterceptor: HttpLoggingInterceptor
): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.connectTimeout(NetworkConstants.DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(NetworkConstants.DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.writeTimeout(NetworkConstants.DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
}

Custom Qualifiers​

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class NoAuthClient

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class NoAuthRetrofit

These qualifiers disambiguate the two OkHttpClient/Retrofit instances. @NoAuthClient and @NoAuthRetrofit are used only by AuthApiSync (the synchronous auth API for token refresh inside TokenAuthenticator).

Retrofit Instances​

// Primary Retrofit β€” used by all API interfaces
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient, baseUrlProvider: BaseUrlProvider): Retrofit =
Retrofit.Builder()
.baseUrl(baseUrlProvider.getBaseUrl())
.client(okHttpClient)
.addConverterFactory(NullOnEmptyConverterFactory())
.addConverterFactory(GsonConverterFactory.create())
.build()

// Auth-free Retrofit β€” used only for AuthApiSync
@Provides
@Singleton
@NoAuthRetrofit
fun provideNoAuthRetrofit(
@NoAuthClient okHttpClient: OkHttpClient,
baseUrlProvider: BaseUrlProvider
): Retrofit = Retrofit.Builder()
.baseUrl(baseUrlProvider.getBaseUrl())
.client(okHttpClient)
.addConverterFactory(NullOnEmptyConverterFactory())
.addConverterFactory(GsonConverterFactory.create())
.build()

API Interfaces​

All API interfaces are provided as singletons:

@Provides @Singleton
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)

@Provides @Singleton
fun provideAuthApiSync(@NoAuthRetrofit retrofit: Retrofit): AuthApiSync =
retrofit.create(AuthApiSync::class.java)

@Provides @Singleton
fun provideDeviceApi(retrofit: Retrofit): DeviceApi = retrofit.create(DeviceApi::class.java)

@Provides @Singleton
fun providePositionApi(retrofit: Retrofit): PositionApi = retrofit.create(PositionApi::class.java)

@Provides @Singleton
fun provideEventApi(retrofit: Retrofit): EventApi = retrofit.create(EventApi::class.java)

@Provides @Singleton
fun provideGeofenceApi(retrofit: Retrofit): GeofenceApi = retrofit.create(GeofenceApi::class.java)

@Provides @Singleton
fun provideSharingApi(retrofit: Retrofit): SharingApi = retrofit.create(SharingApi::class.java)

@Provides @Singleton
fun provideSubscriptionApi(retrofit: Retrofit): SubscriptionApi = retrofit.create(SubscriptionApi::class.java)

@Provides @Singleton
fun provideCommandApi(retrofit: Retrofit): CommandApi = retrofit.create(CommandApi::class.java)

@Provides @Singleton
fun provideBillingApi(retrofit: Retrofit): BillingApi = retrofit.create(BillingApi::class.java)

@Provides @Singleton
fun provideNotificationApi(retrofit: Retrofit): NotificationApi = retrofit.create(NotificationApi::class.java)

Token Management Module​

TokenManagerModule provides encrypted token storage and the TokenManager that caches credentials in memory:

@Module
@InstallIn(SingletonComponent::class)
object TokenManagerModule {
@Provides
@Singleton
fun provideEncryptedTokenDataStore(
@ApplicationContext context: Context
): EncryptedTokenDataStore = EncryptedTokenDataStore(context)

@Provides
@Singleton
fun provideTokenManager(
@ApplicationContext context: Context,
dataStore: EncryptedTokenDataStore
): TokenManager = TokenManager(context, dataStore)
}

TokenManager is an open class (not an interface) so it can be subclassed by FakeTokenManager in tests. It exposes accessToken, refreshToken, userId, isLoggedIn, and methods like saveTokens() and clearTokens().


Build-Type Specific Modules​

Billing is the only dependency that varies by build type. The BillingModule file defines qualifiers, while DebugBillingModule and ReleaseBillingModule live in their respective source sets.

BillingModule (Qualifiers Only)​

// src/main/java/.../di/BillingModule.kt
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class RealBilling

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class FakeBilling

DebugBillingModule (src/debug/)​

Uses FakeBillingManager to bypass Google Play while still verifying purchases against the real backend:

@Module
@InstallIn(SingletonComponent::class)
object DebugBillingModule {
@Provides
@Singleton
fun provideBillingManager(
@ApplicationContext context: Context,
billingApi: BillingApi
): IBillingManager = FakeBillingManager { purchaseToken, productId ->
billingApi.verifyAndroidPurchase(
VerifyPurchaseRequest(
purchaseToken = purchaseToken,
productId = productId
)
)
}

@Provides
@Singleton
fun provideBillingRepository(impl: BillingRepositoryImpl): BillingRepository = impl
}

ReleaseBillingModule (src/release/)​

Uses the real BillingManager that connects to Google Play:

@Module
@InstallIn(SingletonComponent::class)
object ReleaseBillingModule {
@Provides
@Singleton
fun provideBillingRepository(impl: BillingRepositoryImpl): BillingRepository = impl

@Provides
@Singleton
fun provideBillingManager(
@ApplicationContext context: Context,
billingRepository: BillingRepository
): IBillingManager = BillingManager(context) { purchaseToken, productId ->
billingRepository.verifyAndroidPurchase(purchaseToken, productId)
}
}

Both modules provide the same types (IBillingManager and BillingRepository) β€” Gradle includes only one based on the build type, so there is never a duplicate binding conflict.


Service Module​

@Module
@InstallIn(SingletonComponent::class)
abstract class ServiceModule {
@Binds
@Singleton
abstract fun bindPushTokenManager(impl: FCMTokenManagerImpl): PushTokenManager
}

Binds the Firebase Cloud Messaging token manager to the domain-layer PushTokenManager interface.


App-Level Modules​

AppModule​

@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideLocaleManager(
@ApplicationContext context: Context
): LocaleManager = LocaleManager.getInstance(context)
}

AppPreferencesModule​

@Module
@InstallIn(SingletonComponent::class)
object AppPreferencesModule {
@Provides
@Singleton
fun provideAppPreferencesDataStore(
@ApplicationContext context: Context
): AppPreferencesDataStore = AppPreferencesDataStore(context)
}

BaseUrlModule​

@Module
@InstallIn(SingletonComponent::class)
object BaseUrlModule {
@Provides
@Singleton
fun provideBaseUrlProvider(): BaseUrlProvider = ProductionBaseUrlProvider()
}

BaseUrlProvider is an interface, making it replaceable in tests.


Scoping​

@Singleton (App-Wide)​

Every module installs into SingletonComponent and uses @Singleton. This means:

  • Repositories β€” one instance shared across all ViewModels and services
  • API interfaces β€” Retrofit proxies are stateless; singleton avoids re-creation
  • OkHttpClient / Retrofit β€” expensive to create, must be shared
  • TokenManager β€” caches auth state in memory, must be single source of truth

@HiltViewModel (ViewModel Scope)​

ViewModels are scoped to their owning Activity or Fragment lifecycle via @HiltViewModel:

@HiltViewModel
class AuthNavigationViewModel @Inject constructor(
private val tokenManager: TokenManager,
private val authRepository: AuthRepository
) : IntentViewModel<AuthNavigationIntent>()

Hilt creates a new ViewModel instance per lifecycle owner. The injected singletons (tokenManager, authRepository) are shared across all ViewModels.


@Binds vs @Provides​

The codebase uses both patterns, each for a specific purpose:

@Binds β€” Interface-to-Implementation Mapping​

Used when an @Inject-annotated implementation directly satisfies an interface. The module must be an abstract class:

@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository
}

Used by: RepositoryModule (11 bindings), ServiceModule (1 binding)

@Provides β€” Factory Methods​

Used when construction requires logic, external libraries, or types without @Inject:

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient, baseUrlProvider: BaseUrlProvider): Retrofit =
Retrofit.Builder()
.baseUrl(baseUrlProvider.getBaseUrl())
.client(okHttpClient)
.addConverterFactory(NullOnEmptyConverterFactory())
.addConverterFactory(GsonConverterFactory.create())
.build()
}

Used by: AppModule, NetworkModule, TokenManagerModule, BaseUrlModule, AppPreferencesModule, DebugBillingModule, ReleaseBillingModule

When to Use Which​

ScenarioUse
Binding an interface to its @Inject-annotated impl@Binds
Creating third-party objects (Retrofit, OkHttp)@Provides
Construction requires parameters not in the graph@Provides
Simple 1:1 interface mapping@Binds (preferred β€” generates less code)

Test DI Overrides​

Instrumented tests replace production modules using @TestInstallIn.

Test Runner​

class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(
cl: ClassLoader?, className: String?, context: Context?
): Application = super.newApplication(cl, HiltTestApplication::class.java.name, context)
}

Configured in build.gradle:

android {
defaultConfig {
testInstrumentationRunner = "com.visla.vislagps.HiltTestRunner"
}
}

TestBaseUrlModule​

Replaces BaseUrlModule to point at a local mock server:

@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [BaseUrlModule::class]
)
object TestBaseUrlModule {
@Provides
@Singleton
fun provideBaseUrlProvider(): BaseUrlProvider = TestBaseUrlProvider
}

object TestBaseUrlProvider : BaseUrlProvider {
var url: String = "http://localhost:8080/"
override fun getBaseUrl(): String = url
}

Tests can change the URL at runtime:

TestBaseUrlProvider.url = mockWebServer.url("/").toString()

TestTokenModule​

Replaces TokenManagerModule with a controllable fake:

@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [TokenManagerModule::class]
)
object TestTokenModule {
@Provides
@Singleton
fun provideFakeTokenManager(
@ApplicationContext context: Context
): TokenManager = FakeTokenManager(context)
}

FakeTokenManager extends TokenManager and provides test helpers:

class FakeTokenManager(context: Context) : TokenManager(context) {
companion object {
var startLoggedIn = false
}

private var fakeIsLoggedIn = startLoggedIn
private var fakeAccessToken: String? = null
private var fakeUserId: Int? = null

override val isLoggedIn: Boolean
get() = fakeIsLoggedIn

override var accessToken: String?
get() = fakeAccessToken
set(value) { fakeAccessToken = value }

override var userId: Int?
get() = fakeUserId
set(value) { fakeUserId = value }

fun setLoggedIn(loggedIn: Boolean, token: String? = "test-token", userId: Int? = 1) {
fakeIsLoggedIn = loggedIn
if (loggedIn) {
fakeAccessToken = token
fakeUserId = userId
} else {
fakeAccessToken = null
fakeUserId = null
}
}

fun reset() {
fakeIsLoggedIn = startLoggedIn
fakeAccessToken = null
fakeUserId = null
}

override fun saveTokens(accessToken: String, refreshToken: String?, userId: Int, email: String?, name: String?) {
fakeAccessToken = accessToken
fakeUserId = userId
fakeIsLoggedIn = true
}

override fun clearTokens() {
fakeAccessToken = null
fakeUserId = null
fakeIsLoggedIn = false
}
}

How @TestInstallIn Works​

  1. Hilt sees @TestInstallIn(replaces = [BaseUrlModule::class]) during test compilation
  2. It removes BaseUrlModule from the component and installs TestBaseUrlModule instead
  3. All production code that depends on BaseUrlProvider now receives TestBaseUrlProvider
  4. No changes to production code required

How to Add a New Repository​

Follow these steps to add a new repository to the DI graph.

Step 1: Create the Interface​

// domain/repositories/TrackRepository.kt
interface TrackRepository {
suspend fun getTracks(deviceId: Int): List<Track>
suspend fun getTrack(id: Int): Track
}

Step 2: Create the Implementation​

// data/repositories/TrackRepositoryImpl.kt
@Singleton
class TrackRepositoryImpl @Inject constructor(
private val trackApi: TrackApi,
private val trackMapper: TrackMapper
) : TrackRepository {
override suspend fun getTracks(deviceId: Int): List<Track> =
trackApi.getTracks(deviceId).map(trackMapper::toDomain)

override suspend fun getTrack(id: Int): Track =
trackMapper.toDomain(trackApi.getTrack(id))
}

Step 3: Add the API Interface (if needed)​

// data/remote/api/TrackApi.kt
interface TrackApi {
@GET("api/tracks")
suspend fun getTracks(@Query("deviceId") deviceId: Int): List<TrackDto>

@GET("api/tracks/{id}")
suspend fun getTrack(@Path("id") id: Int): TrackDto
}

Step 4: Provide the API in NetworkModule​

// di/NetworkModule.kt β€” add this method
@Provides
@Singleton
fun provideTrackApi(retrofit: Retrofit): TrackApi = retrofit.create(TrackApi::class.java)

Step 5: Bind the Repository in RepositoryModule​

// di/RepositoryModule.kt β€” add this binding
@Binds
@Singleton
abstract fun bindTrackRepository(impl: TrackRepositoryImpl): TrackRepository

Step 6: Inject It​

@HiltViewModel
class TrackViewModel @Inject constructor(
private val trackRepository: TrackRepository
) : IntentViewModel<TrackIntent>()

Build the project β€” Hilt validates the entire graph at compile time. Missing bindings produce clear error messages.


Design Decisions​

Why Hilt over Koin or Manual DI?​

  • Compile-time safety β€” Hilt validates the entire dependency graph during compilation. Missing bindings, circular dependencies, and scope mismatches are caught before the app runs. Koin discovers these at runtime.
  • Google-supported β€” Hilt is the recommended DI framework for Android, built on top of Dagger. It integrates with Jetpack components (@HiltViewModel, @AndroidEntryPoint) out of the box.
  • Android-optimized β€” Predefined component hierarchy (SingletonComponent, ActivityComponent, ViewModelComponent) matches Android lifecycle scopes. No manual scope management.
  • Code generation β€” Dagger/Hilt generates the component implementation at compile time, avoiding reflection-based service locators.

Why @Singleton for Repositories?​

  • Stateless β€” Repository implementations are stateless wrappers around API clients. Creating multiple instances wastes memory without benefit.
  • Thread-safe β€” Singleton repositories backed by Retrofit (which is thread-safe) can be safely called from any coroutine dispatcher.
  • Shared state β€” Some repositories (like DeviceRepositoryImpl) expose StateFlow for reactive UI updates. A single instance ensures all observers see the same state.

Why Separate Billing Modules per Build Type?​

  • Testability β€” Debug builds use FakeBillingManager to simulate purchases without a Google Play connection. Developers can test the full purchase flow on emulators.
  • No conditional logic β€” Instead of if (BuildConfig.DEBUG) checks scattered through the code, Gradle includes only the relevant module. The production APK never contains fake billing code.
  • Backend verification preserved β€” Even with fake billing, the debug module still calls the real backend to verify purchases. This tests the full server-side flow.

Why @Binds for Repositories?​

  • Less generated code β€” @Binds tells Dagger the binding without generating a factory method. @Provides generates an additional wrapper.
  • Cleaner β€” A one-line abstract method is more readable than a @Provides function that just returns its parameter.
  • Convention β€” @Binds signals "this is a simple interface mapping" while @Provides signals "construction logic lives here."

Why Two OkHttpClient Instances?​

The TokenAuthenticator refreshes expired tokens by calling AuthApiSync. If AuthApiSync used the same OkHttpClient (which has TokenAuthenticator attached), a 401 during token refresh would trigger another refresh β€” creating an infinite loop or deadlock. The @NoAuthClient / @NoAuthRetrofit qualified instances break this cycle.