Network Configuration
The network layer uses OkHttp + Retrofit with Hilt dependency injection. All networking is configured in NetworkModule and BaseUrlModule.
Key source files:
| File | Purpose |
|---|---|
di/NetworkModule.kt | OkHttpClient, Retrofit, and API interface providers |
di/BaseUrlModule.kt | Base URL provider binding |
core/network/AuthInterceptor.kt | Adds Bearer token to requests |
core/network/TokenAuthenticator.kt | Handles 401 responses with token refresh |
core/network/NullOnEmptyConverterFactory.kt | Handles empty response bodies |
core/network/BaseUrlProvider.kt | Interface + production implementation for base URL |
core/network/NetworkConstants.kt | Timeout and retry constants |
OkHttpClient Setupβ
The app uses a dual client pattern β two OkHttpClient instances distinguished by Hilt qualifiers:
Authenticated Client (default)β
Used by all API interfaces except AuthApiSync. Includes the auth interceptor, token authenticator, and logging:
@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()
No-Auth Client (@NoAuthClient)β
Used exclusively for token refresh to avoid circular dependencies. Has logging but no auth interceptor or authenticator:
@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()
Qualifiersβ
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class NoAuthClient
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class NoAuthRetrofit
Timeoutsβ
All timeouts are defined in NetworkConstants:
| Constant | Value | Used For |
|---|---|---|
DEFAULT_TIMEOUT_SECONDS | 30L | connect / read / write on both OkHttpClients |
LIGHT_TIMEOUT_MS | 5000 | Lightweight operations (used outside Retrofit) |
RETRY_DELAY_MS | 1000L | Retry backoff delay |
Loggingβ
A single shared HttpLoggingInterceptor at Level.BODY is added to both clients:
@Provides
@Singleton
fun provideLoggingInterceptor(): HttpLoggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
Retrofit Configurationβ
Two Retrofit instances mirror the two OkHttpClients:
// Default β used by all regular API interfaces
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient, baseUrlProvider: BaseUrlProvider): Retrofit =
Retrofit.Builder()
.baseUrl(baseUrlProvider.getBaseUrl())
.client(okHttpClient)
.addConverterFactory(NullOnEmptyConverterFactory())
.addConverterFactory(GsonConverterFactory.create())
.build()
// No-auth β used only by 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()
Both use the same converter stack:
NullOnEmptyConverterFactoryβ must come first (see below)GsonConverterFactoryβ JSON serialization
API Interface Bindingβ
Each Retrofit API interface gets a @Provides @Singleton binding. AuthApiSync uses the @NoAuthRetrofit instance; everything else uses the default:
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)
fun provideAuthApiSync(@NoAuthRetrofit retrofit: Retrofit): AuthApiSync =
retrofit.create(AuthApiSync::class.java)
fun provideDeviceApi(retrofit: Retrofit): DeviceApi = retrofit.create(DeviceApi::class.java)
// ... same pattern for PositionApi, EventApi, GeofenceApi, SharingApi,
// SubscriptionApi, CommandApi, BillingApi, NotificationApi
AuthInterceptorβ
AuthInterceptor adds a Bearer token to every request except public endpoints:
@Singleton
class AuthInterceptor @Inject constructor(
private val tokenManager: TokenManager
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val path = originalRequest.url.encodedPath
if (shouldSkipAuth(path)) {
return chain.proceed(originalRequest)
}
val token = tokenManager.accessToken
if (token == null) {
return chain.proceed(originalRequest)
}
val authenticatedRequest = originalRequest.newBuilder()
.header("Authorization", "Bearer $token")
.build()
return chain.proceed(authenticatedRequest)
}
}
Public Path Exclusionsβ
These paths skip token injection entirely (login, registration, token refresh, etc.):
private fun shouldSkipAuth(path: String): Boolean {
val publicPaths = listOf(
"/api/auth/login",
"/api/auth/register",
"/api/auth/verify-code",
"/api/auth/verify",
"/api/auth/resend-verification",
"/api/auth/refresh",
"/api/auth/openid",
"/api/auth/password",
"/api/legal"
)
return publicPaths.any { path.startsWith(it) }
}
Matching uses startsWith so sub-paths (e.g. /api/auth/password/reset) are also excluded.
TokenAuthenticatorβ
TokenAuthenticator handles 401 responses by synchronously refreshing the access token:
@Singleton
class TokenAuthenticator @Inject constructor(
private val tokenManager: TokenManager,
private val authApiSync: AuthApiSync
) : Authenticator {
@Volatile
private var isRefreshing = false
override fun authenticate(route: Route?, response: Response): Request? {
// Don't retry if we've already tried
if (response.request.header("Authorization-Retry") != null) {
return null
}
val refreshToken = tokenManager.refreshToken ?: return null
synchronized(this) {
if (isRefreshing) return null
isRefreshing = true
}
return try {
val call = authApiSync.refreshToken(RefreshTokenRequest(refreshToken))
val apiResponse = call.execute()
val newTokenResponse = if (apiResponse.isSuccessful) apiResponse.body() else null
val newAccessToken = newTokenResponse?.accessToken
val newUser = newTokenResponse?.user
if (!newAccessToken.isNullOrBlank() && newUser != null) {
tokenManager.saveTokens(
accessToken = newAccessToken,
refreshToken = newTokenResponse.refreshToken ?: refreshToken,
userId = newUser.id,
email = newUser.email,
name = newUser.name
)
response.request.newBuilder()
.header("Authorization", "Bearer $newAccessToken")
.header("Authorization-Retry", "true")
.build()
} else {
tokenManager.clearTokens()
null
}
} catch (e: Exception) {
Logger.error("Token refresh failed", mapOf("error" to (e.message ?: "unknown")))
tokenManager.clearTokens()
null
} finally {
isRefreshing = false
}
}
}
Why Synchronousβ
OkHttp's Authenticator.authenticate() runs on OkHttp's dispatcher threads. Using runBlocking with Kotlin coroutines there can deadlock when all OkHttp threads are blocked waiting for a token refresh that itself needs an OkHttp thread. The AuthApiSync interface returns Call<LoginResponseDto> instead of a suspend function, and the authenticator calls call.execute() for a truly synchronous HTTP call that doesn't consume a coroutine dispatcher thread.
Synchronized Blockβ
The synchronized(this) block with the @Volatile isRefreshing flag prevents multiple concurrent 401 responses from triggering simultaneous refresh requests. If a refresh is already in progress, additional callers return null immediately (letting OkHttp fail the request) rather than hammering the refresh endpoint.
Retry Guardβ
The Authorization-Retry header prevents infinite loops. If a refreshed token still produces a 401, the authenticator sees this header and returns null to stop retrying.
NullOnEmptyConverterFactoryβ
class NullOnEmptyConverterFactory : Converter.Factory() {
override fun responseBodyConverter(
type: Type,
annotations: Array<out Annotation>,
retrofit: Retrofit
): Converter<ResponseBody, *>? {
val delegate = retrofit.nextResponseBodyConverter<Any>(this, type, annotations)
return Converter<ResponseBody, Any?> { body ->
if (body.contentLength() == 0L) null else delegate.convert(body)
}
}
}
Why it exists: GsonConverterFactory throws an EOFException when it tries to parse an empty response body (e.g. 204 No Content). This factory intercepts responses with contentLength() == 0L and returns null before Gson ever sees them. It must be registered before GsonConverterFactory in the Retrofit builder so it gets first chance at conversion.
Base URL Configurationβ
BaseUrlProvider Interfaceβ
interface BaseUrlProvider {
fun getBaseUrl(): String
}
The production implementation reads from BuildConfig:
class ProductionBaseUrlProvider : BaseUrlProvider {
override fun getBaseUrl(): String = BuildConfig.API_BASE_URL.trimEnd('/') + "/"
}
trimEnd('/') + "/" guarantees a trailing slash, which Retrofit requires for base URLs.
Hilt Bindingβ
// BaseUrlModule.kt
@Module
@InstallIn(SingletonComponent::class)
object BaseUrlModule {
@Provides
@Singleton
fun provideBaseUrlProvider(): BaseUrlProvider = ProductionBaseUrlProvider()
}
The module is separate from NetworkModule so tests can replace it with a module providing a BaseUrlProvider pointing at MockWebServer without replacing the entire network graph.
Build Type URLsβ
Configured in app/build.gradle.kts:
| Build Type | Source | Default |
|---|---|---|
| debug | local.properties β API_BASE_URL | https://api.vislagps.com |
| release | Hardcoded | https://api.vislagps.com |
// build.gradle.kts
val apiBaseUrl = keystoreProperties.getProperty("API_BASE_URL") ?: "https://api.vislagps.com"
buildTypes {
debug {
buildConfigField("String", "API_BASE_URL", "\"${apiBaseUrl}\"")
}
release {
buildConfigField("String", "API_BASE_URL", "\"https://api.vislagps.com\"")
}
}
For local development against an emulator, override in local.properties:
API_BASE_URL=http://10.0.2.2:8080
10.0.2.2 is the Android emulator's alias for the host machine's localhost.
Release builds always use the production URL regardless of local.properties.
Design Decisionsβ
Dual Client Patternβ
The authenticated client has AuthInterceptor + TokenAuthenticator. The token refresh call itself must not go through those components, or it would create a circular dependency:
- Request fails with 401 β
TokenAuthenticatorfires - Authenticator calls refresh endpoint
- If that call went through the authenticated client,
AuthInterceptorwould try to attach the (expired) token, and a 401 on the refresh call would triggerTokenAuthenticatoragain β infinite loop / deadlock
The no-auth client has only the logging interceptor, breaking this cycle. AuthApiSync is explicitly wired to @NoAuthRetrofit so the refresh call is plain HTTP with no auth logic.
Timeout Valuesβ
All three timeouts (connect, read, write) are set to 30 seconds via NetworkConstants.DEFAULT_TIMEOUT_SECONDS. This is conservative enough for mobile networks with high latency while avoiding indefinite hangs. The same value is used on both clients for consistency.
Converter Orderingβ
NullOnEmptyConverterFactory is registered before GsonConverterFactory in both Retrofit instances. Retrofit tries converters in registration order, so empty bodies are caught before Gson attempts to parse them.
Singleton Scopeβ
All networking components (OkHttpClient, Retrofit, API interfaces, AuthInterceptor, TokenAuthenticator) are @Singleton scoped. This ensures connection pooling is shared app-wide and only one token refresh can be in-flight at a time.