Skip to content

Latest commit

 

History

History
270 lines (207 loc) · 9.61 KB

File metadata and controls

270 lines (207 loc) · 9.61 KB

Android Offline-Aware Network Cookbook

Android CI License Kotlin Compose

简体中文 | English

Login and network request demo Token / Profile result demo

Clone. Run. Watch the network layer behave.

Turn airplane mode on, expire a token, fire concurrent 401s — and see what a production-ready OkHttp stack should do.

Most Retrofit samples stop at “hello, JSON.” This project focuses on the two bugs that still break real apps:

Pain What this cookbook shows
Weak / no network Wi-Fi revalidates, cellular caches for 60s, offline serves stale data for 4 weeks
Expired access token One safe refresh, one retry, no recursion, no NPE under concurrent 401

The Compose demo talks to an in-process MockWebServer. No account. No cloud API. No secrets. No internet required.


30-second demo

  1. Run the app → fetch on CELLULAR → response source: NETWORK
  2. Switch to OFFLINE → fetch again → source: CACHE (timeline stays quiet)
  3. Tap Expire access token → fetch online → watch 401 → refresh → retry in the server timeline

You see response source, cache policy, token state, and every local server hit — networking stops being invisible.


Quick start

Requirements: Android Studio · JDK 17 · Android SDK 36

git clone https://github.com/cheng2016/Retrofit2RxJava-Android-Simples.git
cd Retrofit2RxJava-Android-Simples
./gradlew assembleDebug

Verify locally:

./gradlew lintDebug testDebugUnitTest assembleDebug

Architecture

flowchart LR
    ComposeUI[ComposeScreen] --> ViewModel[NetworkDemoViewModel]
    ViewModel --> Repository[ProfileRepository]
    Repository --> Retrofit[RetrofitApi]
    Retrofit --> OkHttp[OkHttpClient]
    OkHttp --> Cache[NetworkAwareCacheInterceptor]
    OkHttp --> Auth[TokenAuthenticator]
    Auth --> Store[DataStoreTokenStore]
    OkHttp --> Mock[LocalMockWebServer]
Loading

Stack: Kotlin · Jetpack Compose · MVVM · Coroutines / StateFlow · Hilt · Retrofit · OkHttp · DataStore · MockWebServer


Three copy-paste recipes

Full sources: NetworkAwareCacheInterceptor.kt · TokenAuthenticator.kt · NetworkModule.kt · DemoRepository.kt

1. Offline-aware cache

Rewrite Cache-Control from a swappable NetworkStatusProvider. In production, back it with ConnectivityManager.

Mode Behavior
WIFI public, max-age=0 — always revalidate
CELLULAR public, max-age=60 — soft cache for one minute
OFFLINE FORCE_CACHE + max-stale=2419200 (4 weeks)
Offline miss OkHttp 504 → clear UI error
override fun intercept(chain: Interceptor.Chain): Response {
    val mode = networkStatusProvider.current()
    var request = chain.request()

    // Offline: force cache only; OkHttp returns 504 when nothing is cached
    if (mode == NetworkMode.OFFLINE) {
        request = request.newBuilder()
            .cacheControl(CacheControl.FORCE_CACHE)
            .build()
    }

    val originalResponse = chain.proceed(request)
    val cacheControl = when (mode) {
        NetworkMode.WIFI -> "public, max-age=0"
        NetworkMode.CELLULAR -> "public, max-age=60"
        NetworkMode.OFFLINE -> "public, only-if-cached, max-stale=2419200"
    }
    return originalResponse.newBuilder()
        .removeHeader("Pragma")
        .removeHeader("Cache-Control")
        .header("Cache-Control", cacheControl)
        .build()
}

Install the interceptor on both the application and network chains so rewritten headers are stored on disk:

OkHttpClient.Builder()
    .cache(Cache(cacheDir, 10L * 1024 * 1024))
    .addInterceptor(cacheInterceptor)          // FORCE_CACHE offline
    .addInterceptor(authorizationInterceptor)  // Bearer token
    .addNetworkInterceptor(cacheInterceptor)   // persist Cache-Control
    .authenticator(tokenAuthenticator)
    .build()

2. Safe token refresh

TokenAuthenticator uses a dedicated refresh client without an Authenticator (no recursion):

  • No refresh token / failed refresh → return null and stop
  • At most one authenticated retry
  • Concurrent 401s share a single-flight refresh
  • If another call already refreshed → retry with the new token
override fun authenticate(route: Route?, response: Response): Request? {
    // Give up after the original 401 + one authenticated retry
    if (responseCount(response) >= 2) return null

    val requestToken = extractBearerToken(
        response.request.header("Authorization"),
    )

    synchronized(refreshLock) {
        val currentAccess = tokenStore.getAccessToken()
        // Another call already refreshed — retry with the new token
        if (!currentAccess.isNullOrBlank() &&
            requestToken != null &&
            currentAccess != requestToken
        ) {
            return response.request.newBuilder()
                .header("Authorization", "Bearer $currentAccess")
                .build()
        }

        val refreshToken = tokenStore.getRefreshToken()
        if (refreshToken.isNullOrBlank()) return null

        // Dedicated client (no Authenticator), synchronous refresh
        val refreshResponse = tokenRefreshApi
            .refresh(RefreshTokenRequest(refreshToken))
            .execute()
        val body = refreshResponse.body()
        if (!refreshResponse.isSuccessful || body == null) return null

        tokenStore.saveTokens(body.accessToken, body.refreshToken)
        return response.request.newBuilder()
            .header("Authorization", "Bearer ${body.accessToken}")
            .build()
    }
}

Inject the access token on every request:

class AuthorizationInterceptor(
    private val tokenStore: TokenStore,
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val accessToken = tokenStore.getAccessToken()
        if (accessToken.isNullOrBlank()) return chain.proceed(chain.request())
        val authenticated = chain.request().newBuilder()
            .header("Authorization", "Bearer $accessToken")
            .build()
        return chain.proceed(authenticated)
    }
}

Debug BODY logs redact Authorization. Release logging is off.

3. Observable results for UI

The UI should know more than success/failure. DemoRepository surfaces cache hits and token refresh:

suspend fun getProfile(): DemoOutcome<ProfileNetworkResult> {
    val accessBefore = tokenStore.getAccessToken()
    val response = demoApi.getProfile()
    val raw = response.raw()
    val tokenRefreshed = accessBefore != tokenStore.getAccessToken()

    if (response.code() == 504) {
        return DemoOutcome.Failure(DemoError.OfflineNoCache())
    }

    val source = when {
        raw.networkResponse != null -> ResponseSource.NETWORK
        raw.cacheResponse != null -> ResponseSource.CACHE
        else -> ResponseSource.NETWORK
    }

    return DemoOutcome.Success(
        ProfileNetworkResult(
            body = response.body()!!,
            source = source,
            cacheMetadata = CacheMetadata(
                cacheResponsePresent = raw.cacheResponse != null,
                networkResponsePresent = raw.networkResponse != null,
                sentRequestAtMillis = raw.sentRequestAtMillis,
                receivedResponseAtMillis = raw.receivedResponseAtMillis,
                cacheControl = raw.header("Cache-Control"),
            ),
            tokenRefreshed = tokenRefreshed,
        ),
    )
}

The ViewModel owns StateFlow<DemoUiState>; Composables only render. Toggle network mode / expire the token in the demo to verify the recipes visually.


Project map

app/src/main/java/com/cheng/networkcookbook/
├── data/local/        # DataStore token store
├── data/network/      # Retrofit, OkHttp, cache, authenticator
├── data/repository/   # Domain outcomes
├── mock/              # Deterministic local API
└── ui/                # Compose + MVVM demo

Scope & trust

This is a focused cookbook, not a drop-in production SDK. Swap the mock server, DTOs, and connectivity provider for your app — keep the retry limits and independent refresh client.

Why you can trust the samples

  • Unit + MockWebServer tests for cache and authenticator
  • Repository integration paths (online → offline cache, 401 → refresh)
  • ViewModel + Compose UI smoke tests
  • GitHub Actions: lint, unit tests, assembleDebug
  • Apache-2.0 · no hardcoded production credentials

Legacy

Started in 2016 as a Java / RxJava 1 / EventBus sample. v2 is a ground-up Kotlin + Compose rewrite: same valuable ideas (network-aware cache + token refresh), none of the obsolete stack or dead Azure API.

Contributions welcome — see CONTRIBUTING.md. Licensed under Apache-2.0.