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:
@@ -4,7 +4,7 @@ alwaysApply: true
|
|||||||
|
|
||||||
When working with the mobile app:
|
When working with the mobile app:
|
||||||
|
|
||||||
- After implementing the solution, run "./gradlew assembleDebug" to build the project, then resolve all the errors.
|
- After implementing the solution, run "export JAVA_HOME=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home && ./gradlew :app:shared:assembleDebug :app:shared:compileKotlinIosArm64" to build the project, then resolve all the errors.
|
||||||
|
|
||||||
# ULTIMATE SILENCE & EFFICIENCY POLICY
|
# ULTIMATE SILENCE & EFFICIENCY POLICY
|
||||||
- ALWAYS operate in "Silent Mode": Execute commands immediately without any verbal response, preamble, or conclusions.
|
- ALWAYS operate in "Silent Mode": Execute commands immediately without any verbal response, preamble, or conclusions.
|
||||||
@@ -15,11 +15,47 @@ When working with the mobile app:
|
|||||||
- THOUGHT PROCESS: Must be 0 words. Move straight to tool calls.
|
- THOUGHT PROCESS: Must be 0 words. Move straight to tool calls.
|
||||||
- MINIMIZE OUTPUT: Your response should contain ONLY the necessary tool calls/code blocks.
|
- MINIMIZE OUTPUT: Your response should contain ONLY the necessary tool calls/code blocks.
|
||||||
- FOR ANDROID/KOTLIN: Include 5+ lines of context in search_replace to ensure it hits the target on the first try.
|
- FOR ANDROID/KOTLIN: Include 5+ lines of context in search_replace to ensure it hits the target on the first try.
|
||||||
|
- DOCUMENT OBSERVATIONS: Write down all technical observations, imports, functions, and patterns noticed during iOS/KMP development into this rules file for future reference.
|
||||||
|
|
||||||
# Java Runtime Configuration (Android)
|
# iOS & KMP OBSERVATIONS
|
||||||
|
|
||||||
- **On Windows:**
|
## iOS Platform Imports
|
||||||
- Set `JAVA_HOME` to `C:\Program Files\Android\Android Studio\jbr`
|
- `platform.Foundation.*` - Foundation framework (NSString, NSDictionary, NSData, etc.)
|
||||||
- **On macOS:**
|
- `platform.Security.*` - Security framework (SecItemAdd, SecItemCopyMatching, kSecClass, etc.)
|
||||||
- Set `JAVA_HOME` to `/Applications/Android Studio.app/Contents/jbr/Contents/Home`
|
- `platform.CoreFoundation.*` - CoreFoundation framework (CFDictionaryRef, CFTypeRef, etc.)
|
||||||
- This can be done by adding `export JAVA_HOME=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home` to your shell configuration file (e.g., `~/.zshrc` or `~/.bash_profile`) and then running `source` on the file.
|
- `kotlinx.cinterop.*` - C interop utilities (memScoped, alloc, ptr, value, etc.)
|
||||||
|
- `kotlinx.coroutines.*` - Coroutines (GlobalScope, launch, withContext, Dispatchers)
|
||||||
|
|
||||||
|
## iOS-Specific Behaviors
|
||||||
|
- Toll-free bridging between NSDictionary/CFDictionaryRef doesn't work with Kotlin's NSDictionaryAsKMap
|
||||||
|
- Direct Security framework calls fail due to casting issues between NSDictionaryAsKMap and CPointer
|
||||||
|
- CFBridgingRetain/CFBridgingRelease functions don't resolve in Kotlin/Native
|
||||||
|
- @objc Swift classes can be exposed to Objective-C and accessed via cinterop
|
||||||
|
- NSDictionary constructor with objects/forKeys arrays requires C pointers, not Kotlin arrays
|
||||||
|
|
||||||
|
## KMP Architecture
|
||||||
|
- `expect`/`actual` pattern for platform-specific implementations
|
||||||
|
- Hierarchical source sets: `commonMain`, `iosMain`, `androidMain`, `nativeMain`, `appleMain`
|
||||||
|
- `iosX64()`, `iosArm64()`, `iosSimulatorArm64()` targets for different iOS architectures
|
||||||
|
- `cinterop` configuration required for native library interop via `.def` files
|
||||||
|
- `kotlin.mpp.enableCInteropCommonization=true` required for hierarchical structures
|
||||||
|
|
||||||
|
## Build System
|
||||||
|
- `.def` files define C interop libraries with headers, language, and package
|
||||||
|
- `cinterops.create("name")` or `val name by cinterops.creating` for cinterop setup
|
||||||
|
- `definitionFile.set(file("path"))` to specify .def file location
|
||||||
|
- Different HTTP clients: `io.ktor.client.engine.darwin.Darwin` for iOS, `okhttp` for Android
|
||||||
|
|
||||||
|
## Runtime Patterns
|
||||||
|
- `@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)` for experimental C interop
|
||||||
|
- `@DelicateCoroutinesApi` annotation for GlobalScope.launch
|
||||||
|
- `memScoped { }` for memory-safe C interop operations
|
||||||
|
- `GlobalScope.launch { }` for fire-and-forget background operations on iOS
|
||||||
|
- Platform-specific logging with `ru.fromchat.core.Logger`
|
||||||
|
|
||||||
|
## C Interop Gotchas
|
||||||
|
- NSDictionaryAsKMap (Kotlin's internal NSDictionary wrapper) ≠ NSDictionary (Foundation object)
|
||||||
|
- Security framework expects strict CFDictionaryRef types, not NSDictionary toll-free bridging
|
||||||
|
- C interop paths must be relative to the .def file location or use compilerOpts
|
||||||
|
- Complex Objective-C frameworks like Security are unreliable with direct Kotlin interop
|
||||||
|
- Use Swift → Objective-C → Kotlin cinterop chain for complex native operations
|
||||||
@@ -17,9 +17,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.2.4</string>
|
<string>0.2.5</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>024</string>
|
<string>025</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package ru.fromchat.api
|
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.HttpClient
|
||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.plugins.HttpResponseValidator
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
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.LogLevel
|
||||||
import io.ktor.client.plugins.logging.Logger
|
import io.ktor.client.plugins.logging.Logger
|
||||||
import io.ktor.client.plugins.logging.Logging
|
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.Json
|
||||||
import kotlinx.serialization.json.encodeToJsonElement
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
import ru.fromchat.core.config.Config
|
import ru.fromchat.core.config.Config
|
||||||
import ru.fromchat.utils.failOnError
|
|
||||||
import kotlin.concurrent.Volatile
|
import kotlin.concurrent.Volatile
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@@ -52,6 +55,36 @@ object ApiClient {
|
|||||||
install(WebSockets) {
|
install(WebSockets) {
|
||||||
pingInterval = 5000.milliseconds // Send a ping every 5 seconds to keep the connection alive
|
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
|
@Volatile
|
||||||
@@ -60,17 +93,38 @@ object ApiClient {
|
|||||||
@Volatile
|
@Volatile
|
||||||
var user: User? = null
|
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) =
|
suspend fun login(request: LoginRequest) =
|
||||||
http
|
http
|
||||||
.post("${Config.apiBaseUrl}/login") {
|
.post("${Config.apiBaseUrl}/login") {
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request.also { ru.fromchat.core.Logger.d("ApiClient", "Login request: $it") })
|
setBody(request)
|
||||||
}
|
}
|
||||||
.failOnError()
|
|
||||||
.body<LoginResponse>()
|
.body<LoginResponse>()
|
||||||
.also {
|
.also {
|
||||||
token = it.token
|
token = it.token
|
||||||
user = it.user
|
user = it.user
|
||||||
|
secureSettings.putString("auth_token", it.token)
|
||||||
|
settings.putString("user_info", json.encodeToString(it.user))
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun register(request: RegisterRequest) =
|
suspend fun register(request: RegisterRequest) =
|
||||||
@@ -79,39 +133,55 @@ object ApiClient {
|
|||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}
|
}
|
||||||
.failOnError()
|
|
||||||
|
|
||||||
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
|
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
|
||||||
http
|
http
|
||||||
.get("${Config.apiBaseUrl}/get_messages") {
|
.get("${Config.apiBaseUrl}/get_messages") {
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
bearerAuth(token ?: throw IllegalStateException("Not authenticated"))
|
|
||||||
parameter("limit", limit)
|
parameter("limit", limit)
|
||||||
beforeId?.let { parameter("before_id", it) }
|
beforeId?.let { parameter("before_id", it) }
|
||||||
}
|
}
|
||||||
.failOnError()
|
|
||||||
.body<MessagesResponse>()
|
.body<MessagesResponse>()
|
||||||
|
|
||||||
suspend fun send(message: String) =
|
suspend fun send(message: String) =
|
||||||
http
|
http
|
||||||
.post("${Config.apiBaseUrl}/send_message") {
|
.post("${Config.apiBaseUrl}/send_message") {
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
bearerAuth(token!!)
|
|
||||||
setBody(SendMessageRequest(message))
|
setBody(SendMessageRequest(message))
|
||||||
}
|
}
|
||||||
.failOnError()
|
|
||||||
.body<SendMessageResponse>()
|
.body<SendMessageResponse>()
|
||||||
|
|
||||||
|
// Validate token by fetching user profile
|
||||||
suspend fun logout(authToken: String) {
|
suspend fun validateToken(): Boolean {
|
||||||
// Don't throw on logout errors, just try to logout
|
|
||||||
try {
|
try {
|
||||||
http.get("${Config.apiBaseUrl}/logout") {
|
http
|
||||||
bearerAuth(authToken)
|
.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) {
|
} catch (e: Exception) {
|
||||||
// Ignore logout errors
|
// Ignore logout errors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
secureSettings.remove("auth_token")
|
||||||
|
settings.remove("user_info")
|
||||||
|
token = null
|
||||||
|
user = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket send helpers
|
// WebSocket send helpers
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ object Config {
|
|||||||
private val _serverConfig = MutableStateFlow<ServerConfigData?>(null)
|
private val _serverConfig = MutableStateFlow<ServerConfigData?>(null)
|
||||||
val serverConfig: StateFlow<ServerConfigData?> = _serverConfig.asStateFlow()
|
val serverConfig: StateFlow<ServerConfigData?> = _serverConfig.asStateFlow()
|
||||||
|
|
||||||
private val config
|
private val config: ServerConfigData get() {
|
||||||
get() = _serverConfig.value ?: throw IllegalStateException("Server configuration not initialized")
|
if (_serverConfig.value == null) initialize()
|
||||||
|
|
||||||
|
return (_serverConfig.value ?: IllegalStateException("Server configuration not initialized")) as ServerConfigData
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize configuration by loading from storage
|
* Initialize configuration by loading from storage
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import androidx.compose.runtime.CompositionLocalProvider
|
|||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
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.compose.ui.unit.IntOffset
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
@@ -17,6 +21,7 @@ import androidx.navigation.NavController
|
|||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.WebSocketManager
|
import ru.fromchat.api.WebSocketManager
|
||||||
import ru.fromchat.core.config.Config
|
import ru.fromchat.core.config.Config
|
||||||
import ru.fromchat.ui.auth.LoginScreen
|
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.main.MainScreen
|
||||||
import ru.fromchat.ui.setup.ServerConfigScreen
|
import ru.fromchat.ui.setup.ServerConfigScreen
|
||||||
|
|
||||||
val LocalNavController = compositionLocalOf<NavController> { error("") }
|
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun App() {
|
fun App() {
|
||||||
|
var startDestination by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
runCatching {
|
runCatching {
|
||||||
Config.initialize()
|
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
|
// Observe lifecycle events to manage WebSocket connection
|
||||||
@@ -62,76 +79,88 @@ fun App() {
|
|||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val animationSpec = tween<IntOffset>(400)
|
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(
|
CompositionLocalProvider(
|
||||||
LocalNavController provides navController
|
LocalNavController provides navController
|
||||||
) {
|
) {
|
||||||
NavHost(
|
if (startDestination != null) {
|
||||||
navController = navController,
|
NavHost(
|
||||||
startDestination = "login",
|
navController = navController,
|
||||||
enterTransition = {
|
startDestination = startDestination!!,
|
||||||
slideIntoContainer(
|
enterTransition = {
|
||||||
Start,
|
slideIntoContainer(
|
||||||
animationSpec = animationSpec
|
Start,
|
||||||
)
|
animationSpec = animationSpec
|
||||||
},
|
)
|
||||||
exitTransition = {
|
},
|
||||||
slideOutOfContainer(
|
exitTransition = {
|
||||||
Start,
|
slideOutOfContainer(
|
||||||
animationSpec = animationSpec
|
Start,
|
||||||
)
|
animationSpec = animationSpec
|
||||||
},
|
)
|
||||||
popEnterTransition = {
|
},
|
||||||
slideIntoContainer(
|
popEnterTransition = {
|
||||||
End,
|
slideIntoContainer(
|
||||||
animationSpec = animationSpec
|
End,
|
||||||
)
|
animationSpec = animationSpec
|
||||||
},
|
)
|
||||||
popExitTransition = {
|
},
|
||||||
slideOutOfContainer(
|
popExitTransition = {
|
||||||
End,
|
slideOutOfContainer(
|
||||||
animationSpec = animationSpec
|
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
) {
|
||||||
|
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 {
|
scope.launch {
|
||||||
// Logout
|
// Logout
|
||||||
try {
|
try {
|
||||||
ApiClient.token?.let { token ->
|
ApiClient.logout()
|
||||||
ApiClient.logout(token)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Ignore logout errors
|
// Ignore logout errors
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,9 +152,7 @@ fun ServerConfigScreen() {
|
|||||||
|
|
||||||
// Ensure we're logged out when server config changes
|
// Ensure we're logged out when server config changes
|
||||||
runCatching {
|
runCatching {
|
||||||
ApiClient.token?.let { token ->
|
ApiClient.logout()
|
||||||
ApiClient.logout(token)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear API client state
|
// Clear API client state
|
||||||
|
|||||||
@@ -4,10 +4,33 @@ import io.ktor.client.HttpClient
|
|||||||
import io.ktor.client.HttpClientConfig
|
import io.ktor.client.HttpClientConfig
|
||||||
import io.ktor.client.engine.darwin.Darwin
|
import io.ktor.client.engine.darwin.Darwin
|
||||||
import io.ktor.client.engine.darwin.DarwinClientEngineConfig
|
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(
|
actual fun createPlatformHttpClient(
|
||||||
block: HttpClientConfig<*>.() -> Unit
|
block: HttpClientConfig<*>.() -> Unit
|
||||||
): HttpClient {
|
): HttpClient {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ gson = "2.13.2"
|
|||||||
kotlinxIoBytestring = "0.8.2"
|
kotlinxIoBytestring = "0.8.2"
|
||||||
kotlinxCoroutinesCore = "1.10.2"
|
kotlinxCoroutinesCore = "1.10.2"
|
||||||
kotlinxIoCore = "0.8.2"
|
kotlinxIoCore = "0.8.2"
|
||||||
|
multiplatformSettings = "1.3.0"
|
||||||
serialization = "2.3.0"
|
serialization = "2.3.0"
|
||||||
serialization-json = "1.9.0"
|
serialization-json = "1.9.0"
|
||||||
material = "1.13.0"
|
material = "1.13.0"
|
||||||
activityKtx = "1.12.2"
|
activityKtx = "1.12.2"
|
||||||
navigationCompose = "2.9.1"
|
navigationCompose = "2.9.1"
|
||||||
datastore = "1.2.0"
|
datastore = "1.2.0"
|
||||||
|
security-crypto = "1.1.0-alpha06"
|
||||||
ktor = "3.3.3"
|
ktor = "3.3.3"
|
||||||
slf4j = "1.7.36"
|
slf4j = "1.7.36"
|
||||||
kotlinxDatetime = "0.7.1"
|
kotlinxDatetime = "0.7.1"
|
||||||
@@ -49,9 +51,13 @@ kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.re
|
|||||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization-json" }
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization-json" }
|
||||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||||
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" }
|
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" }
|
||||||
|
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" }
|
||||||
|
multiplatform-settings-coroutines = { module = "com.russhwolf:multiplatform-settings-coroutines", version.ref = "multiplatformSettings" }
|
||||||
|
multiplatform-settings-serialization = { module = "com.russhwolf:multiplatform-settings-serialization", version.ref = "multiplatformSettings" }
|
||||||
navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
|
navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
|
||||||
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "datastore" }
|
datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "datastore" }
|
||||||
|
security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security-crypto" }
|
||||||
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||||
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
||||||
ktor-client-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
ktor-client-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ kotlin {
|
|||||||
implementation(compose.materialIconsExtended)
|
implementation(compose.materialIconsExtended)
|
||||||
implementation(libs.jetbrains.kotlinx.coroutines.core)
|
implementation(libs.jetbrains.kotlinx.coroutines.core)
|
||||||
implementation(libs.navigation.compose)
|
implementation(libs.navigation.compose)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
}
|
}
|
||||||
|
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
@@ -49,10 +50,19 @@ kotlin {
|
|||||||
implementation(libs.biometric)
|
implementation(libs.biometric)
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
implementation(libs.material)
|
implementation(libs.material)
|
||||||
|
implementation(libs.security.crypto)
|
||||||
}
|
}
|
||||||
|
|
||||||
iosMain.dependencies {
|
iosMain.dependencies {
|
||||||
// nothing
|
implementation(libs.ktor.client.core)
|
||||||
|
implementation(libs.ktor.client.darwin)
|
||||||
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
|
implementation(libs.ktor.client.serialization.kotlinx.json)
|
||||||
|
implementation(libs.kotlinx.datetime)
|
||||||
|
// Multiplatform settings for iOS Keychain support
|
||||||
|
implementation(libs.multiplatform.settings)
|
||||||
|
implementation(libs.multiplatform.settings.coroutines)
|
||||||
|
implementation(libs.multiplatform.settings.serialization)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
package com.pr0gramm3r101.utils.settings
|
||||||
|
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import com.pr0gramm3r101.utils.UtilsLibrary.context
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class AndroidSecureSettings : Settings {
|
||||||
|
private val masterKey by lazy {
|
||||||
|
MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val encryptedPrefs by lazy {
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"secure_storage",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun putString(key: String, value: String) = withContext(Dispatchers.IO) {
|
||||||
|
encryptedPrefs.edit { putString(key, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getString(key: String, default: String) = withContext(Dispatchers.IO) {
|
||||||
|
encryptedPrefs.getString(key, default) ?: default
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun putInt(key: String, value: Int) = withContext(Dispatchers.IO) {
|
||||||
|
encryptedPrefs.edit { putInt(key, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getInt(key: String, default: Int) = withContext(Dispatchers.IO) {
|
||||||
|
encryptedPrefs.getInt(key, default)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun putLong(key: String, value: Long) = withContext(Dispatchers.IO) {
|
||||||
|
encryptedPrefs.edit { putLong(key, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getLong(key: String, default: Long) = withContext(Dispatchers.IO) {
|
||||||
|
encryptedPrefs.getLong(key, default)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun putFloat(key: String, value: Float) = withContext(Dispatchers.IO) {
|
||||||
|
encryptedPrefs.edit { putFloat(key, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getFloat(key: String, default: Float) = withContext(Dispatchers.IO) {
|
||||||
|
encryptedPrefs.getFloat(key, default)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun putBoolean(key: String, value: Boolean) = withContext(Dispatchers.IO) {
|
||||||
|
encryptedPrefs.edit { putBoolean(key, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getBoolean(key: String, default: Boolean) = withContext(Dispatchers.IO) {
|
||||||
|
encryptedPrefs.getBoolean(key, default)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun putStringSet(key: String, value: Set<String>) = withContext(Dispatchers.IO) {
|
||||||
|
encryptedPrefs.edit { putStringSet(key, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getStringSet(key: String, default: Set<String>) = withContext(Dispatchers.IO) {
|
||||||
|
encryptedPrefs.getStringSet(key, default) ?: default
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun remove(key: String) = withContext(Dispatchers.IO) {
|
||||||
|
encryptedPrefs.edit { remove(key) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun contains(key: String) = withContext(Dispatchers.IO) {
|
||||||
|
encryptedPrefs.contains(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
-4
@@ -79,10 +79,6 @@ class AndroidSettings(): Settings {
|
|||||||
preferences[stringSetPreferencesKey(key)] ?: default
|
preferences[stringSetPreferencesKey(key)] ?: default
|
||||||
}.first()
|
}.first()
|
||||||
|
|
||||||
override suspend fun putStringList(key: String, value: List<String>) = putStringSet(key, value.toSet())
|
|
||||||
|
|
||||||
override suspend fun getStringList(key: String, default: List<String>) = getStringSet(key, default.toSet()).toList()
|
|
||||||
|
|
||||||
override suspend fun remove(key: String) {
|
override suspend fun remove(key: String) {
|
||||||
dataStore.edit { preferences ->
|
dataStore.edit { preferences ->
|
||||||
preferences.remove(stringPreferencesKey(key))
|
preferences.remove(stringPreferencesKey(key))
|
||||||
|
|||||||
+1
-2
@@ -12,6 +12,5 @@ import com.pr0gramm3r101.utils.UtilsLibrary
|
|||||||
object DataStoreSingleton {
|
object DataStoreSingleton {
|
||||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||||
|
|
||||||
val dataStore: DataStore<Preferences>
|
val dataStore get() = UtilsLibrary.context.dataStore
|
||||||
get() = UtilsLibrary.context.dataStore
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -2,4 +2,4 @@ package com.pr0gramm3r101.utils.settings
|
|||||||
|
|
||||||
actual val settings: Settings get() = AndroidSettings()
|
actual val settings: Settings get() = AndroidSettings()
|
||||||
actual val secureSettings: Settings
|
actual val secureSettings: Settings
|
||||||
get() = TODO("Not yet implemented")
|
get() = AndroidSecureSettings()
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
package com.pr0gramm3r101.utils.settings
|
package com.pr0gramm3r101.utils.settings
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
interface Settings {
|
interface Settings {
|
||||||
companion object {
|
companion object {
|
||||||
inline fun get() = settings
|
inline fun get() = settings
|
||||||
@@ -25,9 +29,24 @@ interface Settings {
|
|||||||
|
|
||||||
suspend fun putStringSet(key: String, value: Set<String>)
|
suspend fun putStringSet(key: String, value: Set<String>)
|
||||||
suspend fun getStringSet(key: String, default: Set<String> = emptySet()): Set<String>
|
suspend fun getStringSet(key: String, default: Set<String> = emptySet()): Set<String>
|
||||||
|
|
||||||
suspend fun putStringList(key: String, value: List<String>)
|
suspend fun putStringList(key: String, value: List<String>) = withContext(Dispatchers.Default) {
|
||||||
suspend fun getStringList(key: String, default: List<String> = emptyList()): List<String>
|
putString(key, Json.encodeToString(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getStringList(key: String, default: List<String> = emptyList()) = withContext(Dispatchers.Default) {
|
||||||
|
try {
|
||||||
|
getString(key, "").let {
|
||||||
|
if (it.isEmpty()) {
|
||||||
|
default
|
||||||
|
} else {
|
||||||
|
Json.decodeFromString<List<String>>(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun remove(key: String)
|
suspend fun remove(key: String)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.pr0gramm3r101.utils.settings
|
||||||
|
|
||||||
|
import com.russhwolf.settings.ExperimentalSettingsApi
|
||||||
|
import com.russhwolf.settings.ExperimentalSettingsImplementation
|
||||||
|
import com.russhwolf.settings.KeychainSettings
|
||||||
|
import com.russhwolf.settings.coroutines.toSuspendSettings
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.IO
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSettingsApi::class, ExperimentalSettingsImplementation::class)
|
||||||
|
class IosSecureSettings : Settings {
|
||||||
|
private val suspendSettings = KeychainSettings(service = "ru.fromchat.secure").toSuspendSettings()
|
||||||
|
|
||||||
|
override suspend fun putString(key: String, value: String) = suspendSettings.putString(key, value)
|
||||||
|
|
||||||
|
override suspend fun getString(key: String, default: String) = suspendSettings.getString(key, default)
|
||||||
|
|
||||||
|
override suspend fun putInt(key: String, value: Int) = suspendSettings.putInt(key, value)
|
||||||
|
|
||||||
|
override suspend fun getInt(key: String, default: Int) = suspendSettings.getInt(key, default)
|
||||||
|
|
||||||
|
override suspend fun putLong(key: String, value: Long) = suspendSettings.putLong(key, value)
|
||||||
|
|
||||||
|
override suspend fun getLong(key: String, default: Long) = suspendSettings.getLong(key, default)
|
||||||
|
|
||||||
|
override suspend fun putFloat(key: String, value: Float) = suspendSettings.putFloat(key, value)
|
||||||
|
|
||||||
|
override suspend fun getFloat(key: String, default: Float) = suspendSettings.getFloat(key, default)
|
||||||
|
|
||||||
|
override suspend fun putBoolean(key: String, value: Boolean) = suspendSettings.putBoolean(key, value)
|
||||||
|
|
||||||
|
override suspend fun getBoolean(key: String, default: Boolean) = suspendSettings.getBoolean(key, default)
|
||||||
|
|
||||||
|
override suspend fun putStringSet(key: String, value: Set<String>) = withContext(Dispatchers.IO) {
|
||||||
|
putStringList(key, value.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getStringSet(key: String, default: Set<String>) = withContext(Dispatchers.IO) {
|
||||||
|
getStringList(key, default.toList()).toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun remove(key: String) = suspendSettings.remove(key)
|
||||||
|
|
||||||
|
override suspend fun contains(key: String) = suspendSettings.hasKey(key)
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
package com.pr0gramm3r101.utils.settings
|
package com.pr0gramm3r101.utils.settings
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import platform.Foundation.NSUserDefaults
|
import platform.Foundation.NSUserDefaults
|
||||||
|
|
||||||
// TODO fix
|
|
||||||
class IosSettings : Settings {
|
class IosSettings : Settings {
|
||||||
private val defaults: NSUserDefaults = NSUserDefaults.standardUserDefaults
|
private val defaults: NSUserDefaults = NSUserDefaults.standardUserDefaults
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ class IosSettings : Settings {
|
|||||||
|
|
||||||
override suspend fun remove(key: String) = defaults.removeObjectForKey(key)
|
override suspend fun remove(key: String) = defaults.removeObjectForKey(key)
|
||||||
|
|
||||||
override suspend fun contains(key: String): Boolean = withContext(Dispatchers.Default) {
|
override suspend fun contains(key: String): Boolean = withContext(Dispatchers.IO) {
|
||||||
defaults.objectForKey(key) != null
|
defaults.objectForKey(key) != null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -2,4 +2,4 @@ package com.pr0gramm3r101.utils.settings
|
|||||||
|
|
||||||
actual val settings: Settings get() = IosSettings()
|
actual val settings: Settings get() = IosSettings()
|
||||||
actual val secureSettings: Settings
|
actual val secureSettings: Settings
|
||||||
get() = TODO("Not yet implemented")
|
get() = IosSecureSettings()
|
||||||
Reference in New Issue
Block a user