Apply ktor-client-auth library to auto refresh token
This commit is contained in:
+25
-29
@@ -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
|
||||
}
|
||||
+18
@@ -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
|
||||
}
|
||||
|
||||
+10
@@ -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
|
||||
|
||||
|
||||
+2
-1
@@ -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)
|
||||
}
|
||||
|
||||
+28
@@ -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 = {
|
||||
|
||||
-12
@@ -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 = {
|
||||
|
||||
+2
-5
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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) {
|
||||
|
||||
+26
-31
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
Reference in New Issue
Block a user