mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Implement proper token storage
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
package ru.fromchat.api
|
||||
|
||||
import com.pr0gramm3r101.utils.settings.secureSettings
|
||||
import com.pr0gramm3r101.utils.settings.settings
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.plugins.HttpResponseValidator
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
import io.ktor.client.plugins.logging.LogLevel
|
||||
import io.ktor.client.plugins.logging.Logger
|
||||
import io.ktor.client.plugins.logging.Logging
|
||||
@@ -20,7 +24,6 @@ import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import ru.fromchat.core.config.Config
|
||||
import ru.fromchat.utils.failOnError
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@@ -52,6 +55,36 @@ object ApiClient {
|
||||
install(WebSockets) {
|
||||
pingInterval = 5000.milliseconds // Send a ping every 5 seconds to keep the connection alive
|
||||
}
|
||||
|
||||
// Set default auth header for all requests
|
||||
defaultRequest {
|
||||
token?.let { authToken ->
|
||||
bearerAuth(authToken)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle HTTP errors and auth errors globally
|
||||
HttpResponseValidator {
|
||||
validateResponse { response ->
|
||||
// Handle auth errors
|
||||
if (response.status.value == 401 || response.status.value == 403) {
|
||||
// Clear invalid token and notify about auth error
|
||||
token = null
|
||||
user = null
|
||||
onAuthError?.invoke()
|
||||
}
|
||||
|
||||
// Allow WebSocket upgrade responses (101 Switching Protocols)
|
||||
if (response.status.value == 101) {
|
||||
return@validateResponse
|
||||
}
|
||||
|
||||
// Throw exception for non-2xx status codes (like failOnError())
|
||||
if (response.status.value !in 200..299) {
|
||||
throw io.ktor.client.plugins.ClientRequestException(response, response.status.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
@@ -60,17 +93,38 @@ object ApiClient {
|
||||
@Volatile
|
||||
var user: User? = null
|
||||
|
||||
// Global auth error handler
|
||||
var onAuthError: (() -> Unit)? = null
|
||||
|
||||
// Load persisted token and user info
|
||||
suspend fun loadPersistedData() {
|
||||
try {
|
||||
val savedToken = secureSettings.getString("auth_token", "")
|
||||
token = savedToken
|
||||
if (!token.isNullOrEmpty()) {
|
||||
val userInfo = settings.getString("user_info", "")
|
||||
if (userInfo.isNotEmpty()) {
|
||||
user = json.decodeFromString(userInfo)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ru.fromchat.core.Logger.e("ApiClient", "Error loading persisted data", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
suspend fun login(request: LoginRequest) =
|
||||
http
|
||||
.post("${Config.apiBaseUrl}/login") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request.also { ru.fromchat.core.Logger.d("ApiClient", "Login request: $it") })
|
||||
setBody(request)
|
||||
}
|
||||
.failOnError()
|
||||
.body<LoginResponse>()
|
||||
.also {
|
||||
token = it.token
|
||||
user = it.user
|
||||
secureSettings.putString("auth_token", it.token)
|
||||
settings.putString("user_info", json.encodeToString(it.user))
|
||||
}
|
||||
|
||||
suspend fun register(request: RegisterRequest) =
|
||||
@@ -79,39 +133,55 @@ object ApiClient {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request)
|
||||
}
|
||||
.failOnError()
|
||||
|
||||
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
|
||||
http
|
||||
.get("${Config.apiBaseUrl}/get_messages") {
|
||||
contentType(ContentType.Application.Json)
|
||||
bearerAuth(token ?: throw IllegalStateException("Not authenticated"))
|
||||
parameter("limit", limit)
|
||||
beforeId?.let { parameter("before_id", it) }
|
||||
}
|
||||
.failOnError()
|
||||
.body<MessagesResponse>()
|
||||
|
||||
suspend fun send(message: String) =
|
||||
http
|
||||
.post("${Config.apiBaseUrl}/send_message") {
|
||||
contentType(ContentType.Application.Json)
|
||||
bearerAuth(token!!)
|
||||
setBody(SendMessageRequest(message))
|
||||
}
|
||||
.failOnError()
|
||||
.body<SendMessageResponse>()
|
||||
|
||||
|
||||
suspend fun logout(authToken: String) {
|
||||
// Don't throw on logout errors, just try to logout
|
||||
// Validate token by fetching user profile
|
||||
suspend fun validateToken(): Boolean {
|
||||
try {
|
||||
http.get("${Config.apiBaseUrl}/logout") {
|
||||
bearerAuth(authToken)
|
||||
http
|
||||
.get("${Config.apiBaseUrl}/api/user/profile")
|
||||
return true // Token is valid if no exception thrown
|
||||
} catch (e: io.ktor.client.plugins.ClientRequestException) {
|
||||
// Check if it's an auth error (401/403)
|
||||
if (e.response.status.value == 401 || e.response.status.value == 403) {
|
||||
return false // Token is invalid
|
||||
}
|
||||
// For other HTTP errors, re-throw (don't treat as token invalid)
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// For network/other errors, re-throw (don't treat as token invalid)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
suspend fun logout() {
|
||||
try {
|
||||
http.get("${Config.apiBaseUrl}/logout")
|
||||
} catch (e: Exception) {
|
||||
// Ignore logout errors
|
||||
}
|
||||
|
||||
secureSettings.remove("auth_token")
|
||||
settings.remove("user_info")
|
||||
token = null
|
||||
user = null
|
||||
}
|
||||
|
||||
// WebSocket send helpers
|
||||
|
||||
@@ -13,8 +13,11 @@ object Config {
|
||||
private val _serverConfig = MutableStateFlow<ServerConfigData?>(null)
|
||||
val serverConfig: StateFlow<ServerConfigData?> = _serverConfig.asStateFlow()
|
||||
|
||||
private val config
|
||||
get() = _serverConfig.value ?: throw IllegalStateException("Server configuration not initialized")
|
||||
private val config: ServerConfigData get() {
|
||||
if (_serverConfig.value == null) initialize()
|
||||
|
||||
return (_serverConfig.value ?: IllegalStateException("Server configuration not initialized")) as ServerConfigData
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize configuration by loading from storage
|
||||
|
||||
@@ -8,6 +8,10 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
@@ -17,6 +21,7 @@ import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.WebSocketManager
|
||||
import ru.fromchat.core.config.Config
|
||||
import ru.fromchat.ui.auth.LoginScreen
|
||||
@@ -25,14 +30,26 @@ import ru.fromchat.ui.chat.PublicChatScreen
|
||||
import ru.fromchat.ui.main.MainScreen
|
||||
import ru.fromchat.ui.setup.ServerConfigScreen
|
||||
|
||||
val LocalNavController = compositionLocalOf<NavController> { error("") }
|
||||
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
|
||||
|
||||
@Composable
|
||||
fun App() {
|
||||
var startDestination by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching {
|
||||
Config.initialize()
|
||||
}
|
||||
|
||||
// Load persisted token and user data
|
||||
ApiClient.loadPersistedData()
|
||||
|
||||
// Now determine start destination based on loaded token
|
||||
val hasToken = ApiClient.token?.isNotEmpty() == true
|
||||
startDestination = if (hasToken) "chat" else "login"
|
||||
|
||||
ru.fromchat.core.Logger.d("App", "Navigation decision - hasToken: $hasToken, token: ${ApiClient.token?.take(10) ?: "null"}")
|
||||
ru.fromchat.core.Logger.d("App", "Starting at screen: $startDestination")
|
||||
}
|
||||
|
||||
// Observe lifecycle events to manage WebSocket connection
|
||||
@@ -62,76 +79,88 @@ fun App() {
|
||||
val navController = rememberNavController()
|
||||
val animationSpec = tween<IntOffset>(400)
|
||||
|
||||
// Set up global auth error handler
|
||||
LaunchedEffect(navController) {
|
||||
ApiClient.onAuthError = {
|
||||
ru.fromchat.core.Logger.d("App", "Global auth error handler triggered, navigating to login")
|
||||
navController.navigate("login") {
|
||||
popUpTo("chat") { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalNavController provides navController
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "login",
|
||||
enterTransition = {
|
||||
slideIntoContainer(
|
||||
Start,
|
||||
animationSpec = animationSpec
|
||||
)
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutOfContainer(
|
||||
Start,
|
||||
animationSpec = animationSpec
|
||||
)
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideIntoContainer(
|
||||
End,
|
||||
animationSpec = animationSpec
|
||||
)
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutOfContainer(
|
||||
End,
|
||||
animationSpec = animationSpec
|
||||
)
|
||||
}
|
||||
) {
|
||||
composable("serverConfig") {
|
||||
ServerConfigScreen()
|
||||
}
|
||||
|
||||
composable("login") {
|
||||
LoginScreen(
|
||||
onLoginSuccess = {
|
||||
navController.navigate("chat") {
|
||||
popUpTo("login") { inclusive = true }
|
||||
}
|
||||
},
|
||||
onNavigateToRegister = { navController.navigate("register") }
|
||||
)
|
||||
}
|
||||
|
||||
composable("register") {
|
||||
RegisterScreen(
|
||||
onRegistered = { navController.navigate("login") }
|
||||
)
|
||||
}
|
||||
|
||||
composable("chat") {
|
||||
MainScreen(
|
||||
onLogout = {
|
||||
navController.navigate("login") {
|
||||
popUpTo("chat") { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable("chats/publicChat") {
|
||||
PublicChatScreen()
|
||||
}
|
||||
|
||||
composable("about") {
|
||||
AboutScreen()
|
||||
}
|
||||
if (startDestination != null) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination!!,
|
||||
enterTransition = {
|
||||
slideIntoContainer(
|
||||
Start,
|
||||
animationSpec = animationSpec
|
||||
)
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutOfContainer(
|
||||
Start,
|
||||
animationSpec = animationSpec
|
||||
)
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideIntoContainer(
|
||||
End,
|
||||
animationSpec = animationSpec
|
||||
)
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutOfContainer(
|
||||
End,
|
||||
animationSpec = animationSpec
|
||||
)
|
||||
}
|
||||
) {
|
||||
composable("serverConfig") {
|
||||
ServerConfigScreen()
|
||||
}
|
||||
|
||||
composable("login") {
|
||||
LoginScreen(
|
||||
onLoginSuccess = {
|
||||
navController.navigate("chat") {
|
||||
popUpTo("login") { inclusive = true }
|
||||
}
|
||||
},
|
||||
onNavigateToRegister = { navController.navigate("register") }
|
||||
)
|
||||
}
|
||||
|
||||
composable("register") {
|
||||
RegisterScreen(
|
||||
onRegistered = { navController.navigate("login") }
|
||||
)
|
||||
}
|
||||
|
||||
composable("chat") {
|
||||
MainScreen(
|
||||
onLogout = {
|
||||
navController.navigate("login") {
|
||||
popUpTo("chat") { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable("chats/publicChat") {
|
||||
PublicChatScreen()
|
||||
}
|
||||
|
||||
composable("about") {
|
||||
AboutScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,9 +208,7 @@ fun SettingsTab(
|
||||
scope.launch {
|
||||
// Logout
|
||||
try {
|
||||
ApiClient.token?.let { token ->
|
||||
ApiClient.logout(token)
|
||||
}
|
||||
ApiClient.logout()
|
||||
} catch (e: Exception) {
|
||||
// Ignore logout errors
|
||||
}
|
||||
|
||||
@@ -152,9 +152,7 @@ fun ServerConfigScreen() {
|
||||
|
||||
// Ensure we're logged out when server config changes
|
||||
runCatching {
|
||||
ApiClient.token?.let { token ->
|
||||
ApiClient.logout(token)
|
||||
}
|
||||
ApiClient.logout()
|
||||
}
|
||||
|
||||
// Clear API client state
|
||||
|
||||
@@ -4,10 +4,33 @@ import io.ktor.client.HttpClient
|
||||
import io.ktor.client.HttpClientConfig
|
||||
import io.ktor.client.engine.darwin.Darwin
|
||||
import io.ktor.client.engine.darwin.DarwinClientEngineConfig
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.headers
|
||||
|
||||
actual fun createPlatformHttpClient(
|
||||
block: HttpClientConfig<*>.() -> Unit
|
||||
): HttpClient {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return HttpClient(Darwin, block as HttpClientConfig<DarwinClientEngineConfig>.() -> Unit)
|
||||
return HttpClient(Darwin) {
|
||||
// Configure default request headers to ensure UTF-8 encoding
|
||||
defaultRequest {
|
||||
contentType(ContentType.Application.Json)
|
||||
headers {
|
||||
append("Accept-Charset", "utf-8")
|
||||
append("Content-Type", "application/json; charset=utf-8")
|
||||
}
|
||||
}
|
||||
|
||||
// Add timeout configuration
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 30000
|
||||
connectTimeoutMillis = 30000
|
||||
}
|
||||
|
||||
// Apply the passed configuration block
|
||||
block(this)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user