Apply ktor-client-auth library to auto refresh token

This commit is contained in:
2026-05-15 22:40:09 +07:00
parent e7c381a953
commit 28901df627
10 changed files with 116 additions and 80 deletions
@@ -10,6 +10,9 @@ import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.HttpSend
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
@@ -24,16 +27,34 @@ actual fun createPlatformHttpClient(
tokenManager: TokenManager,
authApi: AuthApi?
): HttpClient {
val client = HttpClient(OkHttp) {
return HttpClient(OkHttp) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
})
}
install(DefaultRequest) {
tokenManager.getAccessToken()?.let {
header("Authorization", "Bearer $it")
install(Auth) {
bearer {
cacheTokens = true
loadTokens {
val access = tokenManager.getAccessToken()
val refresh = tokenManager.getRefreshToken()
if (access != null && refresh != null) {
BearerTokens(access, refresh)
} else null
}
refreshTokens {
Napier.d(">>>>> [NetworkEngine.android.kt] Access token expired, auto request refresh token...")
val newAccess = authApi?.refreshToken()
if (newAccess != null) {
BearerTokens(newAccess, tokenManager.getRefreshToken()!!)
} else {
tokenManager.clearTokens()
tokenManager.triggerSessionExpired()
throw ForceLogoutException()
}
}
}
}
install(HttpTimeout) { requestTimeoutMillis = 30_000 }
@@ -46,29 +67,4 @@ actual fun createPlatformHttpClient(
level = LogLevel.ALL // set LogLevel.NONE for release
}
}
if (authApi != null) {
client.plugin(HttpSend).intercept { request ->
// Skip interceptor for refresh token request to avoid recursion
if (request.url.pathSegments.contains(ReferKeys.REFRESH_TOKEN)) {
return@intercept execute(request)
}
val originalCall = execute(request)
if (originalCall.response.status == HttpStatusCode.Unauthorized) {
Napier.d(">>>>> [NetworkEngine.ios.kt] Access token expired, auto request refresh token...")
val newToken = authApi.refreshToken()
if (newToken != null) {
request.headers["Authorization"] = "Bearer $newToken"
execute(request)
} else {
tokenManager.clearTokens()
throw ForceLogoutException()
}
} else {
originalCall
}
}
}
return client
}
@@ -3,10 +3,13 @@ package com.digitoolsolutions.app.torquevaultkmp
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage
import com.digitoolsolutions.app.torquevaultkmp.data.storage.ReferKeys
import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager
import com.digitoolsolutions.app.torquevaultkmp.data.storage.load
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class AppViewModel(
private val tokenManager: TokenManager,
@@ -16,6 +19,21 @@ class AppViewModel(
private val _isDarkTheme = mutableStateOf<Boolean?>(storage.load(ReferKeys.THEME))
val isDarkTheme: State<Boolean?> = _isDarkTheme
private val _sessionExpired = mutableStateOf(false)
val sessionExpired: State<Boolean> = _sessionExpired
init {
viewModelScope.launch {
tokenManager.sessionExpired.collectLatest {
_sessionExpired.value = true
}
}
}
fun dismissSessionExpired() {
_sessionExpired.value = false
}
fun isLoggedIn(): Boolean {
return tokenManager.getRefreshToken() != null
}
@@ -1,6 +1,16 @@
package com.digitoolsolutions.app.torquevaultkmp.data.storage
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
class TokenManager(private val storage: AppStorage) {
private val _sessionExpired = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val sessionExpired = _sessionExpired.asSharedFlow()
fun triggerSessionExpired() {
_sessionExpired.tryEmit(Unit)
}
private var accessToken: String? = null
private var refreshToken: String? = null
@@ -13,6 +13,7 @@ import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeViewModel
import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingViewModel
import org.koin.core.module.Module
import org.koin.core.module.dsl.factoryOf
import org.koin.core.module.dsl.singleOf
import org.koin.core.qualifier.named
import org.koin.dsl.module
@@ -41,6 +42,6 @@ val networkModule = module {
val viewModelModule = module {
factoryOf(::HomeViewModel)
factory { AuthViewModel(get(), get()) }
factoryOf(::AppViewModel)
singleOf(::AppViewModel)
factoryOf(::SettingViewModel)
}
@@ -2,6 +2,8 @@ package com.digitoolsolutions.app.torquevaultkmp.screens
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
@@ -14,6 +16,7 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.digitoolsolutions.app.torquevaultkmp.AppViewModel
import com.digitoolsolutions.app.torquevaultkmp.screens.auth.AuthDestination
import com.digitoolsolutions.app.torquevaultkmp.screens.auth.LoginScreen
import com.digitoolsolutions.app.torquevaultkmp.screens.bonded.BondedDestination
@@ -25,12 +28,17 @@ import com.digitoolsolutions.app.torquevaultkmp.screens.logs.LogScreen
import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingDestination
import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingScreen
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import torquevaultkmp.composeapp.generated.resources.Res
import torquevaultkmp.composeapp.generated.resources.btn_login
@Composable
fun MainScreen(
isDarkTheme: Boolean,
onThemeToggle: (Boolean) -> Unit,
isLoggedIn: Boolean,
viewModel: AppViewModel = koinViewModel()
) {
val destinations = listOf(
HomeDestination,
@@ -42,6 +50,26 @@ fun MainScreen(
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
val sessionExpired by viewModel.sessionExpired
if (sessionExpired) {
AlertDialog(
onDismissRequest = { },
title = { Text("Session Expired") },
text = { Text("Your session has expired. Please login again.") },
confirmButton = {
Button(onClick = {
viewModel.dismissSessionExpired()
navController.navigate(AuthDestination.route) {
popUpTo(0) { inclusive = true }
}
}) {
Text(stringResource(Res.string.btn_login))
}
}
)
}
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = {
@@ -78,18 +78,6 @@ fun HomeScreen(
viewModel.loadWorkOrders()
}
LaunchedEffect(viewModel) {
viewModel.error.collectLatest { error ->
if (error is ForceLogoutException) {
navController.navigate(AuthDestination.route) {
popUpTo(0) { inclusive = true }
}
} else {
snackbarHostState.showSnackbar(error.message ?: "Unknown error")
}
}
}
Scaffold(
modifier = Modifier.imePadding(),
topBar = {
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder
import com.digitoolsolutions.app.torquevaultkmp.domain.usecase.FetchWorkOrdersUseCase
import com.digitoolsolutions.app.torquevaultkmp.utils.ForceLogoutException
import io.github.aakira.napier.Napier
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
@@ -16,17 +17,13 @@ class HomeViewModel(
): ViewModel() {
private val _workOrders = MutableStateFlow<List<WorkOrder>>(emptyList())
val workOrders = _workOrders.asStateFlow()
private val _error = MutableSharedFlow<Throwable>()
val error = _error.asSharedFlow()
fun loadWorkOrders() {
viewModelScope.launch {
try {
val result = fetchWorkOrdersUseCase(page = 1)
_workOrders.value = result
} catch (e: Exception) {
_error.emit(e)
Napier.d("$e", tag="HomeViewModel")
}
}
}
@@ -19,6 +19,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.digitoolsolutions.app.torquevaultkmp.components.CircularIcon
import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder
//import com.digitoolsolutions.android.components.ui.CircularIcon
//import com.digitoolsolutions.android.uart.spec.ConnectionState
@@ -27,7 +28,7 @@ import com.digitoolsolutions.app.torquevaultkmp.components.CircularIcon
@Composable
//fun HomeItem(device: UartDevice, modifier: Modifier = Modifier) {
fun HomeItem(device: Any, modifier: Modifier = Modifier) {
fun HomeItem(device: WorkOrder, modifier: Modifier = Modifier) {
OutlinedCard(
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
@@ -50,6 +51,7 @@ fun HomeItem(device: Any, modifier: Modifier = Modifier) {
// style = MaterialTheme.typography.bodyMedium,
// color = MaterialTheme.colorScheme.onSurfaceVariant
// )
Text(text=device.id)
}
Spacer(modifier = Modifier.width(16.dp))
// val statusColor = when (device.status) {
@@ -10,6 +10,9 @@ import io.ktor.client.engine.darwin.Darwin
import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.HttpSend
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
@@ -18,22 +21,40 @@ import io.ktor.client.plugins.plugin
import io.ktor.client.request.header
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
actual fun createPlatformHttpClient(
tokenManager: TokenManager,
authApi: AuthApi?
): HttpClient {
val client = HttpClient(Darwin) {
return HttpClient(Darwin) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
})
}
install(DefaultRequest) {
tokenManager.getAccessToken()?.let {
header("Authorization", "Bearer $it")
install(Auth) {
bearer {
loadTokens {
val access = tokenManager.getAccessToken()
val refresh = tokenManager.getRefreshToken()
if (access != null && refresh != null) {
BearerTokens(access, refresh)
} else null
}
refreshTokens {
Napier.d(">>>>> [NetworkEngine.ios.kt] Access token expired, auto request refresh token...")
val newAccess = authApi?.refreshToken()
if (newAccess != null) {
BearerTokens(newAccess, tokenManager.getRefreshToken()!!)
} else {
tokenManager.clearTokens()
tokenManager.triggerSessionExpired()
throw ForceLogoutException()
}
}
}
}
install(HttpTimeout) { requestTimeoutMillis = 30_000 }
@@ -46,30 +67,4 @@ actual fun createPlatformHttpClient(
level = LogLevel.ALL // set LogLevel.NONE for release
}
}
if (authApi != null) {
client.plugin(HttpSend).intercept { request ->
// Skip interceptor for refresh token request to avoid recursion
if (request.url.pathSegments.contains(ReferKeys.REFRESH_TOKEN)) {
return@intercept execute(request)
}
val originalCall = execute(request)
if (originalCall.response.status == HttpStatusCode.Unauthorized) {
Napier.d(">>>>> [NetworkEngine.ios.kt] Access token expired, auto request refresh token...")
val newToken = authApi.refreshToken()
if (newToken != null) {
request.headers["Authorization"] = "Bearer $newToken"
execute(request)
} else {
tokenManager.clearTokens()
throw ForceLogoutException()
}
} else {
originalCall
}
}
}
return client
}
}
+2 -1
View File
@@ -53,6 +53,7 @@ ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negoti
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
logger-napier = { module = "io.github.aakira:napier", version.ref = "logger" }
@@ -69,7 +70,7 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi
ktor = [
"ktor-client-core",
"ktor-client-content-negotiation",
# "ktor-client-auth",
"ktor-client-auth",
"ktor-client-logging",
"ktor-serialization-kotlinx-json"
]