When your Android app talks to a server, it often sends an authentication token (like a JWT) with each request. If that token expires or is invalid, the server responds with a 401 Unauthorized status. Left unhandled, your users see cryptic errors or get stuck in a broken state.
In this guide you’ll learn:
- What triggers a 401 and why auto-refresh matters
- How to structure your API client to retry once with a fresh token
- How to broadcast logout events to your UI using a shared ViewModel
- Best practices for clean, maintainable Kotlin code
Table of Contents
- Understanding Token Refresh
- Setting Up Your Volley Request Wrapper
- Detecting 401 and Retrying Automatically
- Broadcasting Logout to Your UI
- Putting It All Together
- Best Practices and Tips
- FAQ
Understanding Token Refresh
- Access token: Short-lived token used on every API call.
- Refresh token: Longer-lived credential used to get a new access token when the old one expires.
Workflow:
- App sends API request with access token in
Authorization: Bearer …
. - Server returns 401 Unauthorized if token is expired.
- App catches 401, sends a refresh-token request.
- On success: retry original API call once with the new access token.
- On failure: force user to log in again.
Setting Up Your Volley Request Wrapper
class ApiRequest(
private val context: Context,
private val activity: AppCompatActivity
) {
private val requestQueue = Volley.newRequestQueue(context)
private val communicator by lazy {
ViewModelProvider(activity).get(Communicator::class.java)
}
fun addJsonRequest(
method: Int,
url: String,
jsonBody: JSONObject?,
headers: Map<String, String> = emptyMap(),
maxRetries: Int = 1,
onSuccess: (JSONObject) -> Unit,
onError: ((VolleyError) -> Unit)? = null,
onAuthFailed: (() -> Unit)? = null
) { /* … */ }
}
requestQueue
: your Volley instance.communicator
: a sharedViewModel
to push logout events.
Detecting 401 and Retrying Automatically
Inside addJsonRequest
, nest a helper method makeRequest(token, currentRetry)
:
private fun makeRequest(token: String?, currentRetry: Int) {
val errorListener = Response.ErrorListener { error ->
val statusCode = error.networkResponse?.statusCode
when {
statusCode == 401 && currentRetry < maxRetries -> {
refreshToken { success, newToken ->
if (success && !newToken.isNullOrEmpty()) {
makeRequest(newToken, currentRetry + 1)
} else {
notifyLogout()
}
}
}
statusCode == 401 -> {
notifyLogout()
}
else -> {
onError?.invoke(error)
}
}
}
val finalHeaders = headers.toMutableMap().apply {
token?.takeIf { it.isNotBlank() }?.let {
this["Authorization"] = "Bearer $it"
}
}
val request = object : JsonObjectRequest(
method, url, jsonBody,
Response.Listener(onSuccess), errorListener
) {
override fun getHeaders() = finalHeaders
}
requestQueue.add(request)
}
- Single retry: controlled by
maxRetries
. - Token refresh: in your
refreshToken
callback, you update the token inSharedPreferences
. - Clean up: both on refresh-failure and on second 401, call
notifyLogout()
.
Broadcasting Logout to Your UI
Rather than directly manipulating fragments or activities from the networking layer, you’ll broadcast a “logout event” via a shared LiveData
in your Communicator
ViewModel.
// Communicator.kt
class Communicator : ViewModel() {
// Use a simple sealed class for stronger typing:
sealed class UiEvent {
object Logout : UiEvent()
data class Login(val token: String) : UiEvent()
}
val events = MutableLiveData<UiEvent>()
}
In your ApiRequest
:
private fun notifyLogout() {
communicator.events.postValue(Communicator.UiEvent.Logout)
onAuthFailed?.invoke()
}
Then in any UI component (e.g. FragmentTab4
), observe it:
communicator.events.observe(viewLifecycleOwner) { event ->
when(event) {
is Communicator.UiEvent.Logout -> handleLogoutUi()
else -> {/* ignore */}
}
}
private fun handleLogoutUi() {
// 1) Clear tokens
PreferencesHelper.clearTokens(requireContext())
// 2) Clear web storage & cookies
WebStorage.getInstance().deleteAllData()
CookieManager.getInstance().removeAllCookies(null)
CookieManager.getInstance().flush()
// 3) Navigate to Login screen with animation
parentFragmentManager.commit {
setCustomAnimations(
R.anim.slide_in_left, R.anim.slide_out_right
)
replace(R.id.container, LoginFragment())
addToBackStack(null)
}
}
Putting It All Together
- User action → triggers
ApiRequest.addJsonRequest(...)
. - Volley sends request with current access token header.
- Server → 401 Unauthorized.
- ApiRequest catches it, calls
refreshToken()
. - Refresh succeeds → replays original request with new token.
- Refresh fails (or second 401) →
events.postValue(UiEvent.Logout)
. - FragmentTab4 observes logout event → runs
handleLogoutUi()
.
This keeps your networking, token‐management, and UI layers decoupled, testable, and easy to maintain.
Best Practices and Tips
- Use a sealed class for your LiveData events → compile-time safety.
- Limit retries (e.g.
maxRetries=1
) to avoid infinite loops. - Centralize token storage in a helper (e.g.
PreferencesHelper
). - Clear cookies and WebStorage on logout to avoid stale sessions.
- Animate transitions to give users visual feedback.
- Log meaningful messages at each step for easier debugging.
FAQ
Q: What if refreshToken itself gets a 401?
A: Treat it as “failed to refresh,” and fire the logout event immediately—don’t retry the refresh.
Q: Can I use Retrofit instead of Volley?
A: Absolutely—just implement anOkHttp
interceptor to catch 401s and refresh your token synchronously, then retry the request. The same LiveData‐based logout broadcast applies.
Q: How do I test this flow?
A: Mock your server to return a 401 on the first call, a 200 on the refresh call, then assert that the second original request succeeds. Also test the “refresh fails” branch to confirm the logout UI shows.