From 28901df6278a997f66521227e89f98b3637c26f9 Mon Sep 17 00:00:00 2001 From: lamtruong28 Date: Fri, 15 May 2026 22:40:09 +0700 Subject: [PATCH] Apply ktor-client-auth library to auto refresh token --- .../torquevaultkmp/NetworkEngine.android.kt | 54 ++++++++---------- .../app/torquevaultkmp/AppViewModel.kt | 18 ++++++ .../data/storage/TokenManager.kt | 10 ++++ .../app/torquevaultkmp/di/Modules.kt | 3 +- .../app/torquevaultkmp/screens/MainScreen.kt | 28 +++++++++ .../torquevaultkmp/screens/home/HomeScreen.kt | 12 ---- .../screens/home/HomeViewModel.kt | 7 +-- .../screens/home/views/HomeItem.kt | 4 +- .../app/torquevaultkmp/NetworkEngine.ios.kt | 57 +++++++++---------- gradle/libs.versions.toml | 3 +- 10 files changed, 116 insertions(+), 80 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.android.kt b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.android.kt index 80fc1cc..b82a5e5 100644 --- a/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.android.kt @@ -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 } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/AppViewModel.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/AppViewModel.kt index 21eb3ea..2246810 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/AppViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/AppViewModel.kt @@ -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(storage.load(ReferKeys.THEME)) val isDarkTheme: State = _isDarkTheme + private val _sessionExpired = mutableStateOf(false) + val sessionExpired: State = _sessionExpired + + init { + viewModelScope.launch { + tokenManager.sessionExpired.collectLatest { + _sessionExpired.value = true + } + } + } + + fun dismissSessionExpired() { + _sessionExpired.value = false + } + fun isLoggedIn(): Boolean { return tokenManager.getRefreshToken() != null } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/storage/TokenManager.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/storage/TokenManager.kt index 9214f98..7d01c7f 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/storage/TokenManager.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/storage/TokenManager.kt @@ -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(extraBufferCapacity = 1) + val sessionExpired = _sessionExpired.asSharedFlow() + + fun triggerSessionExpired() { + _sessionExpired.tryEmit(Unit) + } + private var accessToken: String? = null private var refreshToken: String? = null diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Modules.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Modules.kt index f9a107c..0138db0 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Modules.kt @@ -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) } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/MainScreen.kt index a7aefb9..f523bcb 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/MainScreen.kt @@ -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 = { diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/HomeScreen.kt index d0cce46..072b2f8 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/HomeScreen.kt @@ -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 = { diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/HomeViewModel.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/HomeViewModel.kt index 0a879c3..9035ed2 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/HomeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/HomeViewModel.kt @@ -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>(emptyList()) val workOrders = _workOrders.asStateFlow() - - private val _error = MutableSharedFlow() - 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") } } } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/views/HomeItem.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/views/HomeItem.kt index d5b304b..8bdc199 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/views/HomeItem.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/views/HomeItem.kt @@ -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) { diff --git a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.ios.kt index e35f86d..6d9bdbc 100644 --- a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.ios.kt @@ -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 -} +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d371550..315265e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" ] \ No newline at end of file