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β
| Module | Type | Pattern | Purpose |
|---|---|---|---|
AppModule | object | @Provides | Application-level utilities (LocaleManager) |
NetworkModule | object | @Provides | OkHttpClient, Retrofit, all API interfaces |
RepositoryModule | abstract class | @Binds | Binds 11 repository interfaces to implementations |
ServiceModule | abstract class | @Binds | Binds push token manager |
TokenManagerModule | object | @Provides | Encrypted token storage and TokenManager |
BaseUrlModule | object | @Provides | Production base URL provider |
AppPreferencesModule | object | @Provides | DataStore-backed app preferences |
BillingModule | β | Qualifiers only | Defines @RealBilling / @FakeBilling qualifiers |
DebugBillingModule | object | @Provides | Fake billing manager (debug builds) |
ReleaseBillingModule | object | @Provides | Real Google Play billing (release builds) |
TestBaseUrlModule | object | @TestInstallIn | Replaces base URL for instrumented tests |
TestTokenModule | object | @TestInstallIn | Replaces 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β
| Scenario | Use |
|---|---|
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β
- Hilt sees
@TestInstallIn(replaces = [BaseUrlModule::class])during test compilation - It removes
BaseUrlModulefrom the component and installsTestBaseUrlModuleinstead - All production code that depends on
BaseUrlProvidernow receivesTestBaseUrlProvider - 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) exposeStateFlowfor reactive UI updates. A single instance ensures all observers see the same state.
Why Separate Billing Modules per Build Type?β
- Testability β Debug builds use
FakeBillingManagerto 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 β
@Bindstells Dagger the binding without generating a factory method.@Providesgenerates an additional wrapper. - Cleaner β A one-line abstract method is more readable than a
@Providesfunction that just returns its parameter. - Convention β
@Bindssignals "this is a simple interface mapping" while@Providessignals "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.