diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc
index 39a6f70..c4736df 100644
--- a/.cursor/rules/general.mdc
+++ b/.cursor/rules/general.mdc
@@ -4,7 +4,7 @@ alwaysApply: true
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
- 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.
- 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.
+- 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:**
- - Set `JAVA_HOME` to `C:\Program Files\Android\Android Studio\jbr`
-- **On macOS:**
- - Set `JAVA_HOME` to `/Applications/Android Studio.app/Contents/jbr/Contents/Home`
- - 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.
\ No newline at end of file
+## iOS Platform Imports
+- `platform.Foundation.*` - Foundation framework (NSString, NSDictionary, NSData, etc.)
+- `platform.Security.*` - Security framework (SecItemAdd, SecItemCopyMatching, kSecClass, etc.)
+- `platform.CoreFoundation.*` - CoreFoundation framework (CFDictionaryRef, CFTypeRef, etc.)
+- `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
\ No newline at end of file
diff --git a/app/ios/iosApp/Info.plist b/app/ios/iosApp/Info.plist
index e7d3eea..5105bd2 100644
--- a/app/ios/iosApp/Info.plist
+++ b/app/ios/iosApp/Info.plist
@@ -17,9 +17,9 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- 0.2.4
+ 0.2.5
CFBundleVersion
- 024
+ 025
LSRequiresIPhoneOS
NSAppTransportSecurity
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
index 872a5ad..b0fb710 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
@@ -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()
.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()
suspend fun send(message: String) =
http
.post("${Config.apiBaseUrl}/send_message") {
contentType(ContentType.Application.Json)
- bearerAuth(token!!)
setBody(SendMessageRequest(message))
}
- .failOnError()
.body()
-
- 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
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/config/Config.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/core/config/Config.kt
index b841e78..4f72096 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/config/Config.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/config/Config.kt
@@ -13,8 +13,11 @@ object Config {
private val _serverConfig = MutableStateFlow(null)
val serverConfig: StateFlow = _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
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
index ec691bb..491dcda 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
@@ -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 { error("") }
+val LocalNavController = compositionLocalOf { error("NavController not provided") }
@Composable
fun App() {
+ var startDestination by remember { mutableStateOf(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(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()
+ }
+ }
+ }
}
}
-}
\ No newline at end of file
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/SettingsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/SettingsTab.kt
index 1556704..c6e7126 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/SettingsTab.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/SettingsTab.kt
@@ -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
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt
index 1de15ef..baab3d8 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt
@@ -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
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/ApiClient.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/ApiClient.ios.kt
index 1b20225..6b49b61 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/api/ApiClient.ios.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/ApiClient.ios.kt
@@ -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.() -> 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)
+ }
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 39b7aad..f600841 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -17,12 +17,14 @@ gson = "2.13.2"
kotlinxIoBytestring = "0.8.2"
kotlinxCoroutinesCore = "1.10.2"
kotlinxIoCore = "0.8.2"
+multiplatformSettings = "1.3.0"
serialization = "2.3.0"
serialization-json = "1.9.0"
material = "1.13.0"
activityKtx = "1.12.2"
navigationCompose = "2.9.1"
datastore = "1.2.0"
+security-crypto = "1.1.0-alpha06"
ktor = "3.3.3"
slf4j = "1.7.36"
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" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
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" }
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", 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-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" }
diff --git a/utils/shared/build.gradle.kts b/utils/shared/build.gradle.kts
index 8568ec8..9333662 100644
--- a/utils/shared/build.gradle.kts
+++ b/utils/shared/build.gradle.kts
@@ -40,6 +40,7 @@ kotlin {
implementation(compose.materialIconsExtended)
implementation(libs.jetbrains.kotlinx.coroutines.core)
implementation(libs.navigation.compose)
+ implementation(libs.kotlinx.serialization.json)
}
androidMain.dependencies {
@@ -49,10 +50,19 @@ kotlin {
implementation(libs.biometric)
implementation(libs.androidx.appcompat)
implementation(libs.material)
+ implementation(libs.security.crypto)
}
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)
}
}
}
diff --git a/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/AndroidSecureSettings.kt b/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/AndroidSecureSettings.kt
new file mode 100644
index 0000000..602bea1
--- /dev/null
+++ b/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/AndroidSecureSettings.kt
@@ -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) = withContext(Dispatchers.IO) {
+ encryptedPrefs.edit { putStringSet(key, value) }
+ }
+
+ override suspend fun getStringSet(key: String, default: Set) = 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)
+ }
+}
+
diff --git a/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/AndroidSettings.kt b/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/AndroidSettings.kt
index 3ffe097..0959047 100644
--- a/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/AndroidSettings.kt
+++ b/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/AndroidSettings.kt
@@ -79,10 +79,6 @@ class AndroidSettings(): Settings {
preferences[stringSetPreferencesKey(key)] ?: default
}.first()
- override suspend fun putStringList(key: String, value: List) = putStringSet(key, value.toSet())
-
- override suspend fun getStringList(key: String, default: List) = getStringSet(key, default.toSet()).toList()
-
override suspend fun remove(key: String) {
dataStore.edit { preferences ->
preferences.remove(stringPreferencesKey(key))
diff --git a/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/DataStoreSingleton.kt b/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/DataStoreSingleton.kt
index d07fb95..7072024 100644
--- a/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/DataStoreSingleton.kt
+++ b/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/DataStoreSingleton.kt
@@ -12,6 +12,5 @@ import com.pr0gramm3r101.utils.UtilsLibrary
object DataStoreSingleton {
private val Context.dataStore: DataStore by preferencesDataStore(name = "settings")
- val dataStore: DataStore
- get() = UtilsLibrary.context.dataStore
+ val dataStore get() = UtilsLibrary.context.dataStore
}
diff --git a/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.android.kt b/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.android.kt
index 36270ec..8bbb247 100644
--- a/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.android.kt
+++ b/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.android.kt
@@ -2,4 +2,4 @@ package com.pr0gramm3r101.utils.settings
actual val settings: Settings get() = AndroidSettings()
actual val secureSettings: Settings
- get() = TODO("Not yet implemented")
\ No newline at end of file
+ get() = AndroidSecureSettings()
\ No newline at end of file
diff --git a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/Settings.kt b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/Settings.kt
index a72a212..90d391c 100644
--- a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/Settings.kt
+++ b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/Settings.kt
@@ -2,6 +2,10 @@
package com.pr0gramm3r101.utils.settings
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.json.Json
+
interface Settings {
companion object {
inline fun get() = settings
@@ -25,9 +29,24 @@ interface Settings {
suspend fun putStringSet(key: String, value: Set)
suspend fun getStringSet(key: String, default: Set = emptySet()): Set
-
- suspend fun putStringList(key: String, value: List)
- suspend fun getStringList(key: String, default: List = emptyList()): List
+
+ suspend fun putStringList(key: String, value: List) = withContext(Dispatchers.Default) {
+ putString(key, Json.encodeToString(value))
+ }
+
+ suspend fun getStringList(key: String, default: List = emptyList()) = withContext(Dispatchers.Default) {
+ try {
+ getString(key, "").let {
+ if (it.isEmpty()) {
+ default
+ } else {
+ Json.decodeFromString>(it)
+ }
+ }
+ } catch (_: Exception) {
+ default
+ }
+ }
suspend fun remove(key: String)
diff --git a/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/IosSecureSettings.kt b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/IosSecureSettings.kt
new file mode 100644
index 0000000..5a853fe
--- /dev/null
+++ b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/IosSecureSettings.kt
@@ -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) = withContext(Dispatchers.IO) {
+ putStringList(key, value.toList())
+ }
+
+ override suspend fun getStringSet(key: String, default: Set) = 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)
+}
diff --git a/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/IosSettings.kt b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/IosSettings.kt
index 90c510a..183ff90 100644
--- a/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/IosSettings.kt
+++ b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/IosSettings.kt
@@ -1,10 +1,10 @@
package com.pr0gramm3r101.utils.settings
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
import platform.Foundation.NSUserDefaults
-// TODO fix
class IosSettings : Settings {
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 contains(key: String): Boolean = withContext(Dispatchers.Default) {
+ override suspend fun contains(key: String): Boolean = withContext(Dispatchers.IO) {
defaults.objectForKey(key) != null
}
}
\ No newline at end of file
diff --git a/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.ios.kt b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.ios.kt
index 922511a..934aff6 100644
--- a/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.ios.kt
+++ b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.ios.kt
@@ -2,4 +2,4 @@ package com.pr0gramm3r101.utils.settings
actual val settings: Settings get() = IosSettings()
actual val secureSettings: Settings
- get() = TODO("Not yet implemented")
\ No newline at end of file
+ get() = IosSecureSettings()
\ No newline at end of file