Implement proper token storage

This commit is contained in:
2025-12-28 11:44:15 +03:00
Unverified
parent 6d84e5b41d
commit f54066c1c0
18 changed files with 429 additions and 113 deletions
+43 -7
View File
@@ -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
+2 -2
View File
@@ -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,12 +79,23 @@ 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
) { ) {
if (startDestination != null) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = "login", startDestination = startDestination!!,
enterTransition = { enterTransition = {
slideIntoContainer( slideIntoContainer(
Start, Start,
@@ -134,4 +162,5 @@ fun App() {
} }
} }
} }
}
} }
@@ -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)
}
} }
+6
View File
@@ -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" }
+11 -1
View File
@@ -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)
} }
} }
} }
@@ -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)
}
}
@@ -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))
@@ -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
} }
@@ -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
@@ -26,8 +30,23 @@ 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
} }
} }
@@ -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()