From f54066c1c037d0d663323712b12d1b7739bd644a Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sun, 28 Dec 2025 11:44:15 +0300 Subject: [PATCH] Implement proper token storage --- .cursor/rules/general.mdc | 50 +++++- app/ios/iosApp/Info.plist | 4 +- .../kotlin/ru/fromchat/api/ApiClient.kt | 96 ++++++++-- .../kotlin/ru/fromchat/core/config/Config.kt | 7 +- .../commonMain/kotlin/ru/fromchat/ui/App.kt | 165 ++++++++++-------- .../kotlin/ru/fromchat/ui/main/SettingsTab.kt | 4 +- .../fromchat/ui/setup/ServerConfigScreen.kt | 4 +- .../kotlin/ru/fromchat/api/ApiClient.ios.kt | 25 ++- gradle/libs.versions.toml | 6 + utils/shared/build.gradle.kts | 12 +- .../utils/settings/AndroidSecureSettings.kt | 83 +++++++++ .../utils/settings/AndroidSettings.kt | 4 - .../utils/settings/DataStoreSingleton.kt | 3 +- .../utils/settings/SettingsFactory.android.kt | 2 +- .../pr0gramm3r101/utils/settings/Settings.kt | 25 ++- .../utils/settings/IosSecureSettings.kt | 46 +++++ .../utils/settings/IosSettings.kt | 4 +- .../utils/settings/SettingsFactory.ios.kt | 2 +- 18 files changed, 429 insertions(+), 113 deletions(-) create mode 100644 utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/AndroidSecureSettings.kt create mode 100644 utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/IosSecureSettings.kt 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