Skip to main content

Authentication, Security & Token Management

This document covers the complete authentication system in the Visla GPS Android app β€” from login flows through encrypted token storage, automatic refresh, OAuth providers, and two-factor authentication.

Architecture Overview​

Key source paths:

LayerPath
Network corecore/network/ β€” TokenManager, EncryptedTokenDataStore, AuthInterceptor, TokenAuthenticator
OAuth managersdata/auth/ β€” GoogleAuthManager, FacebookAuthManager
API interfacesdata/remote/api/ β€” AuthApi, AuthApiSync
DTOsdata/remote/dto/AuthDtos.kt
Use casesdomain/usecases/auth/ β€” 17 use case classes
Domain errorsdomain/errors/DomainExceptions.kt
UI screensui/screens/ β€” Login, Register, Verification, TwoFactor, ForgotPassword, ResetPassword

Token Lifecycle​

The complete token lifecycle follows five phases:

1. Login β€” Acquire Tokens​

A successful login (email/password, OAuth, or 2FA completion) returns a LoginResponseDto:

data class LoginResponseDto(
val user: UserDto? = null,
val accessToken: String? = null,
val refreshToken: String? = null,
val expiresIn: Int = 3600,
@SerializedName("require_2fa")
val require2fa: Boolean? = null,
@SerializedName("temp_token")
val tempToken: String? = null,
val type: String? = null
)

The ViewModel saves tokens immediately:

tokenManager.saveTokens(accessToken, refreshToken, userId)

2. Store β€” Encrypted Persistence​

TokenManager.saveTokens() writes to in-memory cache and persists via EncryptedTokenDataStore:

open fun saveTokens(
accessToken: String,
refreshToken: String?,
userId: Int,
email: String? = null,
name: String? = null
) {
cachedAccessToken = accessToken
cachedRefreshToken = refreshToken
cachedUserId = userId
email?.let { cachedUserEmail = it }
name?.let { cachedUserName = it }

scope.launch {
dataStore.saveAll(accessToken, refreshToken, userId, email, name)
}

// Sync tokens to widget for API access
WidgetDataStore.saveAccessToken(context, accessToken)
WidgetDataStore.saveRefreshToken(context, refreshToken)
}

Tokens are also synced to WidgetDataStore so home screen widgets can make authenticated API calls independently.

3. Intercept β€” Add Authorization Header​

AuthInterceptor reads from TokenManager's in-memory cache (synchronous, no I/O) and attaches the Bearer token:

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 ?: return chain.proceed(originalRequest)

val authenticatedRequest = originalRequest.newBuilder()
.header("Authorization", "Bearer $token")
.build()

return chain.proceed(authenticatedRequest)
}
}

Public paths (no auth header):

EndpointPurpose
/api/auth/loginEmail/password login
/api/auth/registerNew user registration
/api/auth/verify-codeEmail verification
/api/auth/verifyToken-based email verification
/api/auth/resend-verificationResend verification email
/api/auth/refreshToken refresh
/api/auth/openidOAuth login (Google/Facebook)
/api/auth/passwordPassword reset flow
/api/legalLegal documents

4. Refresh β€” Handle 401 Responses​

TokenAuthenticator intercepts 401 responses and performs a synchronous token refresh:

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

// Prevent multiple simultaneous refresh attempts
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
}
}
}

Key behaviors:

  • Authorization-Retry header β€” Prevents infinite retry loops. If the retried request also returns 401, the authenticator returns null (gives up).
  • synchronized(this) + @Volatile isRefreshing β€” Only one thread refreshes at a time. Concurrent 401s get null (the request fails), avoiding thundering herd on the refresh endpoint.
  • Failure clears all tokens β€” If refresh fails for any reason (network error, invalid refresh token, server error), clearTokens() forces the user back to login.

5. Logout β€” Clear Everything​

open fun clearTokens() {
cachedAccessToken = null
cachedRefreshToken = null
cachedUserId = null
cachedUserEmail = null
cachedUserName = null
scope.launch { dataStore.clear() }
WidgetDataStore.clear(context)
}

Logout clears: in-memory cache, encrypted DataStore, and widget data.


Encrypted Token Storage​

EncryptedTokenDataStore​

All tokens are encrypted at rest using Google Tink's AES256_GCM AEAD primitive, with the encryption key protected by the Android Keystore.

class EncryptedTokenDataStore @Inject constructor(private val context: Context) {

companion object {
private const val KEYSET_NAME = "visla_token_keyset"
private const val KEYSET_PREFS = "visla_token_prefs"
private const val MASTER_KEY_ALIAS = "visla_token_master_key"
private const val MASTER_KEY_URI = "android-keystore://$MASTER_KEY_ALIAS"
}

private val aead: Aead by lazy {
AeadConfig.register()
try {
buildAead()
} catch (e: java.security.InvalidKeyException) {
Log.w("EncryptedTokenDataStore", "Keystore key corrupted, resetting: ${e.message}")
resetKeystoreAndPrefs()
buildAead()
} catch (e: java.security.GeneralSecurityException) {
Log.w("EncryptedTokenDataStore", "Keystore error, resetting: ${e.message}")
resetKeystoreAndPrefs()
buildAead()
}
}
// ...
}

Encryption stack:

Stored fields:

KeyTypeEncrypted
access_tokenStringβœ… AES-256-GCM
refresh_tokenStringβœ… AES-256-GCM
user_idInt❌ (non-sensitive)
user_emailStringβœ… AES-256-GCM
user_nameStringβœ… AES-256-GCM

Keystore Recovery​

If the Android Keystore becomes corrupted (factory reset restore, OS upgrade issue, hardware-backed key loss), the aead lazy initializer catches the exception and auto-recovers:

private fun resetKeystoreAndPrefs() {
try {
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
keyStore.deleteEntry(MASTER_KEY_ALIAS)
} catch (e: Exception) {
Log.w("EncryptedTokenDataStore", "Failed to delete keystore entry: ${e.message}")
}
context.getSharedPreferences(KEYSET_PREFS, Context.MODE_PRIVATE).edit().clear().apply()
runBlocking { context.tokenDataStore.edit { it.clear() } }
}

This deletes the corrupted master key, clears the Tink keyset preferences, and clears all stored tokens. The user must re-login, but the app doesn't crash.

Sync vs Async Access​

The DataStore provides both access patterns:

// Async (coroutine) β€” used for writes and flows
val accessToken: Flow<String?> = context.tokenDataStore.data.map { prefs ->
prefs[Keys.ACCESS_TOKEN]?.let { decrypt(it) }
}

// Sync (runBlocking) β€” used to bootstrap in-memory cache at startup
fun getAccessTokenSync(): String? = runBlocking { accessToken.first() }

The sync accessors are only called once at TokenManager initialization to populate the in-memory cache. All runtime reads come from @Volatile cached fields.


TokenManager​

TokenManager is the central token authority. It maintains a @Volatile in-memory cache for lock-free synchronous reads (required by OkHttp interceptors running on background threads) and delegates persistence to EncryptedTokenDataStore.

@Singleton
open class TokenManager(
private val context: Context,
private val dataStore: EncryptedTokenDataStore
) {
@Volatile private var cachedAccessToken: String? = null
@Volatile private var cachedRefreshToken: String? = null
@Volatile private var cachedUserId: Int? = null
@Volatile private var cachedUserEmail: String? = null
@Volatile private var cachedUserName: String? = null

init {
migrateFromLegacyStorage(context)
loadCacheFromDataStore()
}
// ...
}

JWT Decoding​

TokenManager can extract claims directly from JWT access tokens, avoiding an extra /profile API call:

open fun saveTokensFromJwt(accessToken: String, refreshToken: String? = null) {
val payload = decodeJwtPayload(accessToken)
val userId = payload?.optInt("userId", -1)?.takeIf { it > 0 }
val email = payload?.optString("email")?.takeIf { it.isNotBlank() }

if (userId != null) {
saveTokens(accessToken = accessToken, refreshToken = refreshToken, userId = userId, email = email)
} else {
this.accessToken = accessToken
}
}

Token Expiration​

fun isTokenExpired(): Boolean {
val expirationTime = getTokenExpirationTime() ?: return true
return System.currentTimeMillis() >= expirationTime
}

fun isTokenExpiringSoon(withinSeconds: Long = 300): Boolean {
val expirationTime = getTokenExpirationTime() ?: return true
val timeRemaining = expirationTime - System.currentTimeMillis()
return timeRemaining < withinSeconds * MS_PER_SECOND
}

The isTokenExpiringSoon() method uses a 5-minute (300s) default window for proactive refresh hints.


Migration from Legacy Storage​

On first launch after upgrade, TokenManager migrates tokens from two legacy storage mechanisms:

Priority 1: EncryptedSharedPreferences (visla_secure_prefs)
Priority 2: Plain SharedPreferences (visla_gps_prefs)
private fun migrateFromLegacyStorage(context: Context) {
val migrationPrefs = context.getSharedPreferences("visla_migration", Context.MODE_PRIVATE)
if (migrationPrefs.getBoolean(KEY_MIGRATED_TO_DATASTORE, false)) return

// Try legacy encrypted prefs first
try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()

val encryptedPrefs = EncryptedSharedPreferences.create(
context, LEGACY_ENCRYPTED_PREFS_NAME, masterKey, ...)

val token = encryptedPrefs.getString(KEY_ACCESS_TOKEN, null)
if (token != null) {
scope.launch { dataStore.saveAll(...) }
migrationPrefs.edit { putBoolean(KEY_MIGRATED_TO_DATASTORE, true) }
return
}
} catch (e: Exception) {
// EncryptedSharedPreferences may be corrupted β€” fall through
}

// Try plain legacy prefs
val legacyPrefs = context.getSharedPreferences(LEGACY_PREFS_NAME, Context.MODE_PRIVATE)
val legacyToken = legacyPrefs.getString(KEY_ACCESS_TOKEN, null)
if (legacyToken != null) {
scope.launch {
dataStore.saveAll(...)
legacyPrefs.edit { clear() } // Wipe old unencrypted data
}
}

migrationPrefs.edit { putBoolean(KEY_MIGRATED_TO_DATASTORE, true) }
}

The migration runs exactly once, tracked by a visla_migration SharedPreferences flag. After migration, the old SharedPreferences data is cleared.


OAuth Providers​

Google Sign-In​

Uses the Android Credential Manager API (the modern replacement for deprecated Google Sign-In SDK):

class GoogleAuthManager(private val context: Context) {

companion object {
private const val WEB_CLIENT_ID =
"861438780212-us1il74aga5qt5op4b40urbmjs1j6pvj.apps.googleusercontent.com"
}

private val credentialManager = CredentialManager.create(context)

suspend fun signIn(activity: Activity): Result<String> = withContext(Dispatchers.Main) {
val result = credentialManager.getCredential(
request = buildCredentialRequest(),
context = activity
)
handleSignInResult(result)
}

private fun buildCredentialRequest(): GetCredentialRequest {
val googleIdOption = GetGoogleIdOption.Builder()
.setFilterByAuthorizedAccounts(false)
.setServerClientId(WEB_CLIENT_ID)
.setAutoSelectEnabled(false)
.build()

return GetCredentialRequest.Builder()
.addCredentialOption(googleIdOption)
.build()
}
}

Flow:

Error handling: Cancellation, no credentials available, and credential parsing errors are all handled with Result.failure().

Facebook Sign-In​

Uses the Facebook Login SDK with callback-based authentication:

class FacebookAuthManager private constructor(
private val activityResultRegistryOwner: ActivityResultRegistryOwner,
private val callbackManager: CallbackManager
) {
fun login(onSuccess: (accessToken: String) -> Unit, onError: (Exception) -> Unit) {
LoginManager.getInstance().logInWithReadPermissions(
activityResultRegistryOwner,
listOf("email", "public_profile")
)
}
}

Initialization: FacebookAuthManager must be initialized from MainActivity with the Activity's CallbackManager:

FacebookAuthManager.initWithCallbackManager(activity, callbackManager)

Flow:

Social Login Response Handling​

Both Google and Facebook use cases return a SocialLoginResponse with three possible outcomes:

sealed class SocialLoginResponse {
data class Success(val result: LoginResult) : SocialLoginResponse()
data class Requires2FA(val tempToken: String, val type: String) : SocialLoginResponse()
data class NeedsTermsAcceptance(val result: LoginResult) : SocialLoginResponse()
}

The NeedsTermsAcceptance path handles first-time OAuth users who haven't accepted terms of service yet.


Two-Factor Authentication (2FA)​

2FA Login Flow​

When a user with 2FA enabled logs in, the server returns a temp token instead of access tokens:

data class LoginResponseDto(
// ... normal fields null ...
@SerializedName("require_2fa") val require2fa: Boolean? = null,
@SerializedName("temp_token") val tempToken: String? = null,
val type: String? = null // e.g. "totp"
)

The use case catches this as a TwoFactorRequiredException:

class LoginUseCase @Inject constructor(private val authRepository: AuthRepository) {
suspend operator fun invoke(email: String, password: String): LoginResponse {
// ... validation ...
return try {
val result = authRepository.login(normalizedEmail, password)
LoginResponse.Success(result)
} catch (e: TwoFactorRequiredException) {
LoginResponse.Requires2FA(e.tempToken, e.type)
}
}
}

Complete 2FA login sequence:

2FA Setup​

Setting up 2FA for the first time:

class Setup2FAUseCase @Inject constructor(private val authRepository: AuthRepository) {
suspend operator fun invoke(): TwoFactorSetup = authRepository.setup2FA()
}

The server returns a TOTP secret and an otpauth:// URL for QR code generation:

data class TwoFactorSetupDto(
val secret: String,
@SerializedName("otpauth_url")
val otpauthUrl: String
)

Setup flow:

Backup Codes​

data class BackupCodesDto(
@SerializedName("backup_codes")
val backupCodes: List<String>
)

Backup codes are generated server-side during 2FA verification and shown once. They can be used as one-time passwords if the user loses their authenticator device.

Disabling 2FA​

class Disable2FAUseCase @Inject constructor(private val authRepository: AuthRepository) {
suspend operator fun invoke(code: String) {
authRepository.disable2FA(code)
}
}

Requires a valid TOTP code to disable β€” prevents unauthorized 2FA removal.


Complete Auth Flow Sequence​


Session Management​

What Triggers Logout​

TriggerMechanism
User taps "Log out"LogoutUseCase β†’ DELETE /api/auth/logout β†’ clearTokens()
Token refresh failsTokenAuthenticator catches exception β†’ clearTokens()
Refresh returns invalid responseTokenAuthenticator gets null body/token β†’ clearTokens()
No refresh token availableTokenAuthenticator returns null, request fails with 401
Account deletedDeleteAccountUseCase β†’ server invalidates all sessions

Token Expiry Handling​

The app uses a reactive refresh strategy β€” it does not proactively refresh tokens before expiry. Instead:

  1. Normal request β†’ AuthInterceptor attaches current access token
  2. Server returns 401 β†’ TokenAuthenticator intercepts, calls refresh
  3. Refresh succeeds β†’ New token saved, original request retried with new token
  4. Refresh fails β†’ Tokens cleared, user redirected to login

The isTokenExpiringSoon() method (5-minute window) is available for UI hints but is not used for preemptive refresh.


API Interface​

AuthApi (Async β€” Retrofit Suspend)​

The primary API interface used by the repository layer. All methods are suspend functions.

interface AuthApi {
@POST("api/auth/login")
suspend fun login(@Body request: LoginRequest): LoginResponseDto

@POST("api/auth/2fa/login")
suspend fun login2FA(@Body request: Login2FARequest): LoginResponseDto

@POST("api/auth/openid/token")
suspend fun loginWithGoogle(@Body request: GoogleLoginRequest): LoginResponseDto

@POST("api/auth/openid/token")
suspend fun loginWithFacebook(@Body request: FacebookLoginRequest): LoginResponseDto

@HTTP(method = "DELETE", path = "api/auth/logout", hasBody = false)
suspend fun logout()

@POST("api/auth/refresh")
suspend fun refreshToken(@Body request: RefreshTokenRequest): LoginResponseDto

@POST("api/auth/register")
suspend fun register(@Body request: RegisterRequest): RegisterResponseDto

@POST("api/auth/verify-code")
suspend fun verifyCode(@Body request: VerifyCodeRequest): VerifyCodeResponseDto

@POST("api/auth/2fa/setup")
suspend fun setup2FA(): TwoFactorSetupDto

@POST("api/auth/2fa/verify")
suspend fun verify2FA(@Body request: Verify2FARequest): BackupCodesDto

@POST("api/auth/2fa/disable")
suspend fun disable2FA()

// ... password, profile, legal endpoints
}

AuthApiSync (Synchronous β€” for TokenAuthenticator)​

A minimal interface with only refreshToken, using Retrofit's Call<T> instead of suspend functions:

interface AuthApiSync {
@POST("api/auth/refresh")
fun refreshToken(@Body request: RefreshTokenRequest): Call<LoginResponseDto>
}

Domain Exceptions​

All auth errors are modeled as sealed subclasses of DomainException, enabling exhaustive when matching:

sealed class DomainException(override val message: String, override val cause: Throwable? = null) :
Exception(message, cause)

// Authentication
class AuthenticationException(message: String = "Authentication required", ...)
class InvalidCredentialsException(message: String = "Invalid email or password", ...)
class TwoFactorRequiredException(val tempToken: String, val type: String, ...)
class Invalid2FACodeException(message: String = "Invalid verification code", ...)
class EmailNotVerifiedException(val email: String, ...)
class TermsNotAcceptedException(message: String = "Terms of service must be accepted")
class SessionExpiredException(message: String = "Session expired, please login again")

// Validation (also used in auth flows)
class ValidationException(message: String, val field: String? = null, ...)
class InvalidEmailException(message: String = "Invalid email address")
class WeakPasswordException(message: String = "Password must be at least 8 characters")

Notable: TwoFactorRequiredException carries the tempToken needed to complete the 2FA flow. EmailNotVerifiedException carries the email needed to navigate to the verification screen.


Use Cases​

All auth use cases follow the same pattern: validate inputs β†’ delegate to AuthRepository β†’ return typed result or throw domain exception.

Use CaseInputOutputThrows
LoginUseCaseemail, passwordLoginResponse (Success | Requires2FA)InvalidEmailException, ValidationException
Login2FAUseCasetempToken, codeLoginResultValidationException
LoginWithGoogleUseCaseidTokenSocialLoginResponseValidationException
LoginWithFacebookUseCaseaccessTokenSocialLoginResponseValidationException
RegisterUseCasename, email, password, acceptTermsRegisterResultInvalidEmailException, WeakPasswordException, TermsNotAcceptedException, ValidationException
VerifyEmailUseCaseemail, codeVerifyEmailResultInvalidEmailException, ValidationException
LogoutUseCaseβ€”β€”β€”
RequestPasswordResetUseCaseemailβ€”β€”
ResetPasswordUseCaseemail, code, newPasswordβ€”β€”
VerifyResetCodeUseCaseemail, codeβ€”β€”
ChangePasswordUseCasecurrentPassword, newPasswordβ€”β€”
Setup2FAUseCaseβ€”TwoFactorSetup (secret + QR URL)β€”
Verify2FAUseCasecodeList<String> (backup codes)β€”
Disable2FAUseCasecodeβ€”β€”
GetProfileUseCaseβ€”UserProfileβ€”
UpdateProfileUseCasename?, phone?, timezone?, language?UserProfileβ€”
DeleteAccountUseCaseβ€”β€”β€”

UI Screens​

ScreenViewModelPurpose
LoginScreenLoginViewModelEmail/password login, Google/Facebook login, 2FA inline
RegisterScreenRegisterViewModelNew user registration with validation
VerificationCodeScreenVerificationViewModel6-digit email verification with resend cooldown (30s)
TwoFactorScreenTwoFactorViewModel2FA code entry during login
TwoFactorSetupScreenTwoFactorSetupViewModelQR code display + backup codes during 2FA enrollment
ForgotPasswordScreenForgotPasswordViewModelInitiate password reset
ResetCodeScreenβ€”Verify password reset code
ResetPasswordScreenResetPasswordViewModelSet new password with reset code

Navigation pattern: All screens use event-driven navigation via LaunchedEffect collecting from StateFlow<Event?>. Events are cleared after consumption to prevent recomposition loops.


Design Decisions​

Why Tink over EncryptedSharedPreferences​

EncryptedSharedPreferences (from androidx.security.crypto) has well-documented reliability issues:

  1. Keystore corruption β€” After OS updates or backup/restore, the master key can become invalid, making all data permanently inaccessible. EncryptedSharedPreferences throws a GeneralSecurityException with no recovery path.
  2. No graceful degradation β€” Once corrupted, the file cannot be read or cleared through the EncryptedSharedPreferences API itself.
  3. Threading issues β€” The initialization can throw on some devices due to keystore race conditions.

Tink + manual Keystore management solves all three:

  • The aead lazy initializer catches corruption and auto-recovers by deleting the keystore entry and re-creating it.
  • Tink's key management is more robust and actively maintained by Google's security team.
  • The DataStore backend provides proper coroutine-safe access compared to SharedPreferences' commit/apply semantics.

Why Synchronous Token Refresh​

OkHttp's Authenticator.authenticate() runs on the calling thread (one of OkHttp's connection pool threads). Using runBlocking inside it can cause deadlocks:

Thread 1: Request A β†’ 401 β†’ authenticate() β†’ runBlocking { refreshToken() }
Thread 2: Request B β†’ 401 β†’ authenticate() β†’ runBlocking { refreshToken() }
...
Thread N: refreshToken() network call needs a thread β†’ all threads blocked β†’ DEADLOCK

The solution is AuthApiSync with Retrofit's Call<T>.execute(), which performs a truly synchronous HTTP call without requiring a coroutine dispatcher thread:

interface AuthApiSync {
@POST("api/auth/refresh")
fun refreshToken(@Body request: RefreshTokenRequest): Call<LoginResponseDto>
}

Why a Separate AuthApiSync Interface​

AuthApi uses Retrofit suspend functions, which are routed through the OkHttp client that has AuthInterceptor and TokenAuthenticator installed. If TokenAuthenticator used AuthApi to refresh tokens, the refresh request would itself go through the interceptor chain, creating a circular dependency:

Request β†’ 401 β†’ TokenAuthenticator β†’ AuthApi.refreshToken()
β†’ AuthInterceptor adds (expired) Bearer token
β†’ Server returns 401
β†’ TokenAuthenticator tries to refresh again β†’ infinite loop

AuthApiSync is configured on a separate, bare Retrofit instance without the auth interceptor/authenticator, breaking the cycle.

Why Domain Exceptions for Auth Errors​

Using a sealed DomainException hierarchy instead of generic Exception or error codes provides:

  1. Exhaustive when matching β€” The compiler warns if a new exception type is added without handling it.
  2. Rich error data β€” TwoFactorRequiredException carries the tempToken, EmailNotVerifiedException carries the email β€” the ViewModel has everything it needs to navigate to the correct screen.
  3. Clean separation β€” Network/HTTP errors are mapped to domain exceptions in the repository layer. ViewModels never see HTTP status codes or Retrofit exceptions.

Security Considerations​

  1. Tokens at rest β€” All sensitive tokens are AES-256-GCM encrypted via Tink with Android Keystore-backed keys. Even with root access, tokens cannot be decrypted without the hardware-backed master key.

  2. Tokens in memory β€” Cached in @Volatile fields. Visible to a debugger or memory dump, but this is an accepted tradeoff for O(1) synchronous reads required by OkHttp interceptors.

  3. Token transmission β€” Always over HTTPS. The Authorization: Bearer header is only added to non-public endpoints.

  4. Refresh token rotation β€” The server may issue a new refresh token with each refresh response. The authenticator always saves the latest: newTokenResponse.refreshToken ?: refreshToken (falls back to the existing one if the server doesn't rotate).

  5. No token logging β€” Tokens are never logged in full. GoogleAuthManager logs only TOKEN_PREVIEW_LENGTH characters for debugging.

  6. Widget token isolation β€” WidgetDataStore receives a copy of tokens for widget API access but is stored separately from the main encrypted store.

  7. Retry prevention β€” The Authorization-Retry header prevents infinite 401 β†’ refresh β†’ 401 loops.

  8. Concurrent refresh protection β€” synchronized(this) + @Volatile isRefreshing ensures only one thread performs a token refresh at a time.