Skip to main content

Network Configuration

The network layer uses OkHttp + Retrofit with Hilt dependency injection. All networking is configured in NetworkModule and BaseUrlModule.

Key source files:

FilePurpose
di/NetworkModule.ktOkHttpClient, Retrofit, and API interface providers
di/BaseUrlModule.ktBase URL provider binding
core/network/AuthInterceptor.ktAdds Bearer token to requests
core/network/TokenAuthenticator.ktHandles 401 responses with token refresh
core/network/NullOnEmptyConverterFactory.ktHandles empty response bodies
core/network/BaseUrlProvider.ktInterface + production implementation for base URL
core/network/NetworkConstants.ktTimeout 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:

ConstantValueUsed For
DEFAULT_TIMEOUT_SECONDS30Lconnect / read / write on both OkHttpClients
LIGHT_TIMEOUT_MS5000Lightweight operations (used outside Retrofit)
RETRY_DELAY_MS1000LRetry 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:

  1. NullOnEmptyConverterFactory β€” must come first (see below)
  2. 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 TypeSourceDefault
debuglocal.properties β†’ API_BASE_URLhttps://api.vislagps.com
releaseHardcodedhttps://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:

  1. Request fails with 401 β†’ TokenAuthenticator fires
  2. Authenticator calls refresh endpoint
  3. If that call went through the authenticated client, AuthInterceptor would try to attach the (expired) token, and a 401 on the refresh call would trigger TokenAuthenticator again β†’ 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.