13 Commits

41 changed files with 1114 additions and 1093 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
+11
View File
@@ -0,0 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "gradle" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
+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</string> <string>0.2.5</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>02</string> <string>025</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
+1 -1
View File
@@ -6,7 +6,7 @@ plugins {
alias(libs.plugins.compose.multiplatform) alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.compiler)
alias(libs.plugins.android.kotlin.multiplatform.library) alias(libs.plugins.android.kotlin.multiplatform.library)
kotlin("plugin.serialization") version "1.9.22" alias(libs.plugins.kotlin.serialization)
} }
kotlin { kotlin {
@@ -1,18 +0,0 @@
package ru.fromchat
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.format.char
val DATETIME_FORMAT = LocalDateTime.Format {
day()
char('.')
monthNumber()
char('.')
year()
char(' ')
hour()
char(':')
minute()
}
@@ -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
@@ -60,9 +60,7 @@ data class Message(
val reply_to: Message? = null, val reply_to: Message? = null,
val client_message_id: String? = null, val client_message_id: String? = null,
val reactions: List<ReactionData>? = null val reactions: List<ReactionData>? = null
) { )
val utcTimestamp = "${timestamp}Z"
}
@Serializable @Serializable
data class SendMessageRequest( data class SendMessageRequest(
@@ -36,8 +36,3 @@ data class TypingUpdateData(
val userId: Int, val userId: Int,
val username: String val username: String
) )
@Serializable
data class WebSocketAuthMessage(
val token: String
)
@@ -8,19 +8,75 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import ru.fromchat.ui.Theme import ru.fromchat.ui.Theme
/**
* Server configuration data
*/
data class ServerConfigData(
val serverUrl: String,
val httpsEnabled: Boolean
)
object Settings { object Settings {
private const val MATERIAL_YOU_KEY = "materialYou"
private const val THEME_KEY = "theme"
private const val SERVER_URL_KEY = "server_url"
private const val HTTPS_ENABLED_KEY = "https_enabled"
private val settings = Settings() private val settings = Settings()
private fun runIO(block: suspend CoroutineScope.() -> Unit) { private fun runIO(block: suspend CoroutineScope.() -> Unit) {
CoroutineScope(Dispatchers.IO).launch(block = block) CoroutineScope(Dispatchers.IO).launch(block = block)
} }
private fun serverConfigNotInitialized(): Nothing
= throw IllegalStateException("Server config not initialized")
var materialYou: Boolean var materialYou: Boolean
get() = runBlocking { settings.getBoolean("materialYou", true) } get() = runBlocking { settings.getBoolean(MATERIAL_YOU_KEY, true) }
set(value) = runIO { settings.putBoolean("materialYou", value) } set(value) = runIO { settings.putBoolean(MATERIAL_YOU_KEY, value) }
var theme: Theme var theme: Theme
get() = runBlocking { Theme.entries[settings.getInt("theme", Theme.AsSystem.ordinal)] } get() = runBlocking { Theme.entries[settings.getInt(THEME_KEY, Theme.AsSystem.ordinal)] }
set(value) = runIO { settings.putInt("theme", value.ordinal) } set(value) = runIO { settings.putInt(THEME_KEY, value.ordinal) }
var serverUrl: String
get() = runBlocking {
settings.getString(SERVER_URL_KEY).ifEmpty {
serverConfigNotInitialized()
}
}
set(value) = runIO { settings.putString(SERVER_URL_KEY, value) }
var httpsEnabled: Boolean
get() = runBlocking {
if (settings.contains(HTTPS_ENABLED_KEY))
settings.getBoolean(HTTPS_ENABLED_KEY, true)
else serverConfigNotInitialized()
}
set(value) = runIO { settings.putBoolean(HTTPS_ENABLED_KEY, value) }
val hasServerConfig get() = try {
serverUrl
httpsEnabled
true
} catch (_: IllegalStateException) {
false
}
var serverConfig: ServerConfigData
get() {
if (!hasServerConfig) {
serverUrl = "fromchat.ru"
httpsEnabled = true
return ServerConfigData("fromchat.ru", true)
}
return ServerConfigData(serverUrl, httpsEnabled)
}
set(value) {
serverUrl = value.serverUrl
httpsEnabled = value.httpsEnabled
}
} }
@@ -1,10 +1,10 @@
package ru.fromchat.core.config package ru.fromchat.core.config
import com.pr0gramm3r101.utils.storage.ServerConfigData
import com.pr0gramm3r101.utils.storage.ServerConfigStorage
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import ru.fromchat.core.ServerConfigData
import ru.fromchat.core.Settings
/** /**
* Application configuration * Application configuration
@@ -13,24 +13,24 @@ 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
*/ */
suspend fun initialize() { fun initialize() {
_serverConfig.value = ServerConfigStorage.getConfig() _serverConfig.value = Settings.serverConfig
// Set default values and prevent corruption
ServerConfigStorage.getConfig()
} }
/** /**
* Update server configuration * Update server configuration
*/ */
suspend fun updateServerConfig(config: ServerConfigData) { fun updateServerConfig(config: ServerConfigData) {
ServerConfigStorage.saveConfig(config) Settings.serverConfig = config
_serverConfig.value = config _serverConfig.value = config
} }
@@ -45,9 +45,4 @@ object Config {
*/ */
val webSocketUrl val webSocketUrl
get() = "${if (config.httpsEnabled) "wss" else "ws"}://${config.serverUrl}/api/chat/ws" get() = "${if (config.httpsEnabled) "wss" else "ws"}://${config.serverUrl}/api/chat/ws"
/**
* Checks if server configuration exists
*/
suspend fun hasServerConfig() = ServerConfigStorage.hasConfiguration()
} }
@@ -8,6 +8,10 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
@@ -17,6 +21,7 @@ import androidx.navigation.NavController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import ru.fromchat.api.ApiClient
import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketManager
import ru.fromchat.core.config.Config import ru.fromchat.core.config.Config
import ru.fromchat.ui.auth.LoginScreen import ru.fromchat.ui.auth.LoginScreen
@@ -25,14 +30,26 @@ import ru.fromchat.ui.chat.PublicChatScreen
import ru.fromchat.ui.main.MainScreen import ru.fromchat.ui.main.MainScreen
import ru.fromchat.ui.setup.ServerConfigScreen import ru.fromchat.ui.setup.ServerConfigScreen
val LocalNavController = compositionLocalOf<NavController> { error("") } val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
@Composable @Composable
fun App() { fun App() {
var startDestination by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
runCatching { runCatching {
Config.initialize() Config.initialize()
} }
// Load persisted token and user data
ApiClient.loadPersistedData()
// Now determine start destination based on loaded token
val hasToken = ApiClient.token?.isNotEmpty() == true
startDestination = if (hasToken) "chat" else "login"
ru.fromchat.core.Logger.d("App", "Navigation decision - hasToken: $hasToken, token: ${ApiClient.token?.take(10) ?: "null"}")
ru.fromchat.core.Logger.d("App", "Starting at screen: $startDestination")
} }
// Observe lifecycle events to manage WebSocket connection // Observe lifecycle events to manage WebSocket connection
@@ -62,76 +79,88 @@ fun App() {
val navController = rememberNavController() val navController = rememberNavController()
val animationSpec = tween<IntOffset>(400) val animationSpec = tween<IntOffset>(400)
// Set up global auth error handler
LaunchedEffect(navController) {
ApiClient.onAuthError = {
ru.fromchat.core.Logger.d("App", "Global auth error handler triggered, navigating to login")
navController.navigate("login") {
popUpTo("chat") { inclusive = true }
}
}
}
CompositionLocalProvider( CompositionLocalProvider(
LocalNavController provides navController LocalNavController provides navController
) { ) {
NavHost( if (startDestination != null) {
navController = navController, NavHost(
startDestination = "login", navController = navController,
enterTransition = { startDestination = startDestination!!,
slideIntoContainer( enterTransition = {
Start, slideIntoContainer(
animationSpec = animationSpec Start,
) animationSpec = animationSpec
}, )
exitTransition = { },
slideOutOfContainer( exitTransition = {
Start, slideOutOfContainer(
animationSpec = animationSpec Start,
) animationSpec = animationSpec
}, )
popEnterTransition = { },
slideIntoContainer( popEnterTransition = {
End, slideIntoContainer(
animationSpec = animationSpec End,
) animationSpec = animationSpec
}, )
popExitTransition = { },
slideOutOfContainer( popExitTransition = {
End, slideOutOfContainer(
animationSpec = animationSpec End,
) animationSpec = animationSpec
} )
) {
composable("serverConfig") {
ServerConfigScreen()
}
composable("login") {
LoginScreen(
onLoginSuccess = {
navController.navigate("chat") {
popUpTo("login") { inclusive = true }
}
},
onNavigateToRegister = { navController.navigate("register") }
)
}
composable("register") {
RegisterScreen(
onRegistered = { navController.navigate("login") }
)
}
composable("chat") {
MainScreen(
onLogout = {
navController.navigate("login") {
popUpTo("chat") { inclusive = true }
}
}
)
}
composable("chats/publicChat") {
PublicChatScreen()
}
composable("about") {
AboutScreen()
}
} }
) {
composable("serverConfig") {
ServerConfigScreen()
}
composable("login") {
LoginScreen(
onLoginSuccess = {
navController.navigate("chat") {
popUpTo("login") { inclusive = true }
}
},
onNavigateToRegister = { navController.navigate("register") }
)
}
composable("register") {
RegisterScreen(
onRegistered = { navController.navigate("login") }
)
}
composable("chat") {
MainScreen(
onLogout = {
navController.navigate("login") {
popUpTo("chat") { inclusive = true }
}
}
)
}
composable("chats/publicChat") {
PublicChatScreen()
}
composable("about") {
AboutScreen()
}
}
}
} }
} }
} }
@@ -1,5 +1,6 @@
package ru.fromchat.ui.chat package ru.fromchat.ui.chat
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import kotlin.math.abs import kotlin.math.abs
@@ -7,90 +8,44 @@ import kotlin.math.abs
/** /**
* Get gradient brush for own messages * Get gradient brush for own messages
*/ */
fun getMessageGradient(isDark: Boolean): Brush { fun getMessageGradient(isDark: Boolean) = Brush.linearGradient(
return if (isDark) { colors = if (isDark) {
Brush.linearGradient( listOf(
colors = listOf( Color(0xFF9333EA),
Color(0xFF9333EA), Color(0xFF6366F1),
Color(0xFF6366F1), Color(0xFF2F68C5)
Color(0xFF2F68C5)
),
start = androidx.compose.ui.geometry.Offset(0f, 0f),
end = androidx.compose.ui.geometry.Offset(1000f, 1000f)
) )
} else { } else {
Brush.linearGradient( listOf(
colors = listOf( Color(0xFFB794F6),
Color(0xFFB794F6), Color(0xFF818CF8),
Color(0xFF818CF8), Color(0xFF60A5FA)
Color(0xFF60A5FA)
),
start = androidx.compose.ui.geometry.Offset(0f, 0f),
end = androidx.compose.ui.geometry.Offset(1000f, 1000f)
) )
} },
} start = Offset(0f, 0f),
end = Offset(1000f, 1000f)
)
/** /**
* Get background gradient brushes for chat background * Get gradient brush for own messages
*/ */
fun getBackgroundGradients(isDark: Boolean): List<Brush> { fun getReplyMessageGradient(isDark: Boolean) = Brush.linearGradient(
return if (isDark) { colors = if (isDark) {
listOf( listOf(
Brush.radialGradient( Color(0xFF5f11a6),
colors = listOf( Color(0xFF1418da),
Color(0x26DBA1F9), Color(0xFF1b3d73)
Color.Transparent
),
center = androidx.compose.ui.geometry.Offset(0.2f, 0.8f),
radius = 500f
),
Brush.radialGradient(
colors = listOf(
Color(0x26F3B7BE),
Color.Transparent
),
center = androidx.compose.ui.geometry.Offset(0.8f, 0.2f),
radius = 500f
),
Brush.radialGradient(
colors = listOf(
Color(0x1AD0C1DA),
Color.Transparent
),
center = androidx.compose.ui.geometry.Offset(0.4f, 0.4f),
radius = 500f
)
) )
} else { } else {
listOf( listOf(
Brush.radialGradient( Color(0xFF7836ee),
colors = listOf( Color(0xFF2034f3),
Color(0x15B794F6), Color(0xFF076eed)
Color.Transparent
),
center = androidx.compose.ui.geometry.Offset(0.2f, 0.8f),
radius = 500f
),
Brush.radialGradient(
colors = listOf(
Color(0x15F3B7BE),
Color.Transparent
),
center = androidx.compose.ui.geometry.Offset(0.8f, 0.2f),
radius = 500f
),
Brush.radialGradient(
colors = listOf(
Color(0x0DD0C1DA),
Color.Transparent
),
center = androidx.compose.ui.geometry.Offset(0.4f, 0.4f),
radius = 500f
)
) )
} },
} start = Offset(0f, 0f),
end = Offset(1000f, 1000f)
)
/** /**
* Generate a consistent gradient from a name for avatar fallback * Generate a consistent gradient from a name for avatar fallback
@@ -102,21 +57,21 @@ fun generateGradientFromName(name: String): Brush {
val b = abs((hash / 65536) % 256) val b = abs((hash / 65536) % 256)
// Create two colors based on hash for gradient // Create two colors based on hash for gradient
val color1 = Color(
red = (r + 100).coerceIn(0, 255) / 255f,
green = (g + 100).coerceIn(0, 255) / 255f,
blue = (b + 100).coerceIn(0, 255) / 255f
)
val color2 = Color(
red = (r + 50).coerceIn(0, 255) / 255f,
green = (g + 50).coerceIn(0, 255) / 255f,
blue = (b + 50).coerceIn(0, 255) / 255f
)
return Brush.linearGradient( return Brush.linearGradient(
colors = listOf(color1, color2), colors = listOf(
start = androidx.compose.ui.geometry.Offset(0f, 0f), Color(
end = androidx.compose.ui.geometry.Offset(100f, 100f) red = (r + 100).coerceIn(0, 255) / 255f,
green = (g + 100).coerceIn(0, 255) / 255f,
blue = (b + 100).coerceIn(0, 255) / 255f
),
Color(
red = (r + 50).coerceIn(0, 255) / 255f,
green = (g + 50).coerceIn(0, 255) / 255f,
blue = (b + 50).coerceIn(0, 255) / 255f
)
),
start = Offset(0f, 0f),
end = Offset(100f, 100f)
) )
} }
@@ -1,7 +1,12 @@
package ru.fromchat.ui.chat package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -20,15 +25,16 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -41,6 +47,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@@ -56,6 +63,78 @@ import ru.fromchat.Res
import ru.fromchat.api.Message import ru.fromchat.api.Message
import ru.fromchat.message_placeholder import ru.fromchat.message_placeholder
@Composable
private fun <T> AnimatedPreviewBar(
state: T?,
content: @Composable (T) -> Unit
) {
AnimatedVisibility(
visible = state != null,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
var lastState by remember { mutableStateOf(state) }
LaunchedEffect(state) {
if (state != null) {
lastState = state
}
}
if (state != null || lastState != null) {
content(state ?: lastState!!)
}
}
}
@Composable
private fun PreviewBar(
icon: ImageVector,
title: String,
subtitle: String,
onClose: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp, end = 6.dp, top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@OptIn(ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalHazeMaterialsApi::class)
@Composable @Composable
fun ChatInput( fun ChatInput(
@@ -77,6 +156,7 @@ fun ChatInput(
if (text.isNotBlank()) { if (text.isNotBlank()) {
typingJob?.cancel() typingJob?.cancel()
typingHandler.sendTyping() typingHandler.sendTyping()
@Suppress("AssignedValueIsNeverRead")
typingJob = scope.launch { typingJob = scope.launch {
delay(3000) // 3 seconds delay(3000) // 3 seconds
typingHandler.stopTyping() typingHandler.stopTyping()
@@ -87,204 +167,106 @@ fun ChatInput(
} }
} }
Column( Box(
Modifier.windowInsetsPadding(WindowInsets.navigationBars) modifier = Modifier
.fillMaxWidth()
.background(Color.Transparent)
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
) { ) {
// Reply preview val shape = RoundedCornerShape(24.dp)
replyTo?.let { reply ->
ReplyPreviewBar(
replyTo = reply,
onClose = onClearReply,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.hazeEffect(hazeState, style = HazeMaterials.thick())
)
}
// Edit preview Column(
editingMessage?.let { edit ->
EditPreviewBar(
message = edit,
onClose = onClearEdit,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.hazeEffect(hazeState, style = HazeMaterials.thick())
)
}
// Input field with blur
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(Color.Transparent) .border(
.padding(horizontal = 8.dp, vertical = 8.dp) Dp.Hairline,
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
shape
)
.clip(shape)
.hazeEffect(
state = hazeState,
style = HazeMaterials.thin()
)
) { ) {
Row( AnimatedPreviewBar(replyTo) { replyTo ->
modifier = Modifier.fillMaxWidth(), PreviewBar(
horizontalArrangement = Arrangement.spacedBy(8.dp), icon = Icons.AutoMirrored.Filled.Reply,
verticalAlignment = Alignment.CenterVertically title = "Replying to ${replyTo.username}",
) { subtitle = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
val shape = RoundedCornerShape(24.dp) onClose = { onClearReply() }
)
}
OutlinedTextField( AnimatedPreviewBar(editingMessage) { message ->
value = text, PreviewBar(
onValueChange = onTextChange, icon = Icons.Filled.Edit,
modifier = Modifier title = "Editing message",
.weight(1f) subtitle = message.content.take(50) + if (message.content.length > 50) "..." else "",
.border( onClose = { onClearEdit() }
Dp.Hairline, )
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), }
shape
) OutlinedTextField(
.clip(shape) value = text,
.hazeEffect( onValueChange = onTextChange,
state = hazeState, modifier = Modifier
style = HazeMaterials.thin() .fillMaxWidth()
.animateContentSize(),
placeholder = {
Text(
text = stringResource(Res.string.message_placeholder),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
},
shape = shape,
maxLines = 5,
singleLine = false,
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
errorContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedBorderColor = Color.Transparent,
errorBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent
),
trailingIcon = {
val offset = with(LocalDensity.current) { 20.dp.toPx().toInt() }
AnimatedVisibility(
visible = text.isNotBlank(),
enter = slideInHorizontally(
initialOffsetX = { it + offset },
animationSpec = tween(durationMillis = 300)
), ),
placeholder = { exit = slideOutHorizontally(
Text( targetOffsetX = { it + offset },
text = stringResource(Res.string.message_placeholder), animationSpec = tween(durationMillis = 200)
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
) )
}, ) {
shape = shape, Box(Modifier.padding(end = 5.dp)) {
maxLines = 5, FilledIconButton(
singleLine = false, onClick = {
colors = OutlinedTextFieldDefaults.colors( onSend(text.trim())
focusedContainerColor = Color.Transparent, onTextChange("")
unfocusedContainerColor = Color.Transparent, typingHandler.stopTyping()
errorContainerColor = Color.Transparent, },
disabledContainerColor = Color.Transparent, modifier = Modifier.size(36.dp)
focusedBorderColor = Color.Transparent, ) {
errorBorderColor = Color.Transparent, Icon(
disabledBorderColor = Color.Transparent, imageVector = Icons.AutoMirrored.Filled.Send,
unfocusedBorderColor = Color.Transparent contentDescription = "Send",
), tint = MaterialTheme.colorScheme.onPrimary,
trailingIcon = { modifier = Modifier.size(18.dp)
val offset = with(LocalDensity.current) { 20.dp.toPx().toInt() } )
AnimatedVisibility(
visible = text.isNotBlank(),
enter = slideInHorizontally(
initialOffsetX = { it + offset },
animationSpec = tween(durationMillis = 300)
),
exit = slideOutHorizontally(
targetOffsetX = { it + offset },
animationSpec = tween(durationMillis = 200)
)
) {
Box(Modifier.padding(end = 5.dp)) {
FilledIconButton(
onClick = {
onSend(text.trim())
onTextChange("")
typingHandler.stopTyping()
},
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(18.dp)
)
}
} }
} }
} }
) }
} )
}
}
}
@Composable
private fun ReplyPreviewBar(
replyTo: Message,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier, // hazeEffect moved to ChatInput where it's called
shape = RoundedCornerShape(8.dp),
color = Color.Transparent
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Replying to ${replyTo.username}",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
private fun EditPreviewBar(
message: Message,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier, // hazeEffect moved to ChatInput where it's called
shape = RoundedCornerShape(8.dp),
color = Color.Transparent
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Editing message",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = message.content.take(50) + if (message.content.length > 50) "..." else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
} }
} }
@@ -188,13 +188,12 @@ fun ChatScreen(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
Column( Column(Modifier.fillMaxWidth()) {
horizontalAlignment = Alignment.CenterHorizontally
) {
Text( Text(
text = panelState.title, text = panelState.title,
style = MaterialTheme.typography.titleLarge style = MaterialTheme.typography.titleLarge
) )
AnimatedContent( AnimatedContent(
targetState = currentTypingUsers.isNotEmpty(), targetState = currentTypingUsers.isNotEmpty(),
transitionSpec = { transitionSpec = {
@@ -207,9 +206,6 @@ fun ChatScreen(
typingUsers = currentTypingUsers.map { it.username }, typingUsers = currentTypingUsers.map { it.username },
modifier = Modifier.padding(top = 2.dp) modifier = Modifier.padding(top = 2.dp)
) )
} else {
// Empty space to maintain height
Box(modifier = Modifier.height(0.dp))
} }
} }
} }
@@ -279,7 +275,10 @@ fun ChatScreen(
replyTo = replyTo, replyTo = replyTo,
editingMessage = editingMessage, editingMessage = editingMessage,
onClearReply = { replyTo = null }, onClearReply = { replyTo = null },
onClearEdit = { editingMessage = null }, onClearEdit = {
editingMessage = null
inputText = ""
},
hazeState = hazeState hazeState = hazeState
) )
} }
@@ -308,12 +307,13 @@ fun ChatScreen(
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), // Fill the entire space of the Box modifier = Modifier.fillMaxSize(), // Fill the entire space of the Box
verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom) verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom),
reverseLayout = true
) { ) {
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) } // Spacer for TopAppBar item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } // Spacer for chat input
items( items(
items = panelState.messages, items = panelState.messages.reversed(),
key = { it.id } key = { it.id }
) { message -> ) { message ->
val isAuthor = message.user_id == currentUserId val isAuthor = message.user_id == currentUserId
@@ -350,18 +350,22 @@ fun ChatScreen(
} }
} }
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } // Spacer for chat input item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) } // Spacer for TopAppBar
} }
} }
// Context menu // Context menu
@Suppress("AssignedValueIsNeverRead")
MessageContextMenu( MessageContextMenu(
state = contextMenuState, state = contextMenuState,
isAuthor = contextMenuState.message?.user_id == currentUserId, isAuthor = contextMenuState.message?.user_id == currentUserId,
onDismiss = { contextMenuState = contextMenuState.copy(isOpen = false) }, onDismiss = { contextMenuState = contextMenuState.copy(isOpen = false) },
onReply = { message -> onReply = { message ->
replyTo = message replyTo = message
editingMessage = null if (editingMessage != null) {
editingMessage = null
inputText = ""
}
}, },
onEdit = { message -> onEdit = { message ->
editingMessage = message editingMessage = message
@@ -12,9 +12,12 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
@@ -27,12 +30,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.pr0gramm3r101.utils.conditional
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import ru.fromchat.api.Message import ru.fromchat.api.Message
@@ -92,18 +95,11 @@ fun MessageItem(
.widthIn(max = 280.dp), .widthIn(max = 280.dp),
horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start
) { ) {
// Reply preview
message.reply_to?.let { replyTo ->
ReplyPreview(
replyTo = replyTo,
modifier = Modifier.padding(bottom = 4.dp)
)
}
// Message bubble // Message bubble
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
Box( Box(
modifier = Modifier modifier = Modifier
.width(IntrinsicSize.Min)
.clip( .clip(
RoundedCornerShape( RoundedCornerShape(
topStart = 20.dp, topStart = 20.dp,
@@ -112,35 +108,27 @@ fun MessageItem(
bottomEnd = if (isAuthor) 8.dp else 20.dp bottomEnd = if (isAuthor) 8.dp else 20.dp
) )
) )
.then( .conditional(
if (isAuthor) { isAuthor,
Modifier.shadow( `if` = {
elevation = 8.dp, it
shape = RoundedCornerShape( .shadow(
topStart = 20.dp, elevation = 8.dp,
topEnd = 20.dp, shape = RoundedCornerShape(
bottomStart = 20.dp, topStart = 20.dp,
bottomEnd = 8.dp topEnd = 20.dp,
), bottomStart = 20.dp,
spotColor = if (isDark) Color(0x66000000) else Color(0x33000000) bottomEnd = 8.dp
) ),
} else { spotColor = if (isDark) Color(0x66000000) else Color(0x33000000)
Modifier
}
)
.background(
brush = if (isAuthor) {
getMessageGradient(isDark)
} else {
Brush.linearGradient(
listOf(
MaterialTheme.colorScheme.surfaceContainerHighest,
MaterialTheme.colorScheme.surfaceContainerHighest
) )
) .background(getMessageGradient(isDark))
},
`else` = {
background(MaterialTheme.colorScheme.surfaceContainerHighest)
} }
) )
.padding(horizontal = 12.dp, vertical = 8.dp) .padding(top = 6.dp)
) { ) {
Column { Column {
// Username inside bubble (for received messages) // Username inside bubble (for received messages)
@@ -150,10 +138,59 @@ fun MessageItem(
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 4.dp) modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 4.dp)
) )
} }
// Reply preview
message.reply_to?.let { replyTo ->
Box(
Modifier.padding(bottom = 4.dp, start = 6.dp, end = 6.dp)
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.fillMaxWidth()
.height(IntrinsicSize.Min)
.conditional(
isAuthor,
`if` = {
background(getReplyMessageGradient(isDark))
},
`else` = {
background(MaterialTheme.colorScheme.surfaceVariant)
}
)
) {
Box(
Modifier
.background(MaterialTheme.colorScheme.primary)
.width(3.dp)
.fillMaxHeight()
)
Column(
Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
) {
Text(
text = replyTo.username,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
fontSize = 11.sp
)
Text(
text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
maxLines = 1
)
}
}
}
}
// Message content // Message content
Text( Text(
text = message.content, text = message.content,
@@ -162,12 +199,14 @@ fun MessageItem(
Color.White Color.White
} else { } else {
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
} },
modifier = Modifier.padding(horizontal = 12.dp)
) )
// Timestamp and edited indicator // Timestamp and edited indicator
Row( Row(
modifier = Modifier.padding(top = 4.dp), modifier = Modifier
.padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -202,45 +241,17 @@ fun MessageItem(
} }
} }
@Composable
private fun ReplyPreview(
replyTo: Message,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 8.dp, vertical = 6.dp)
) {
Column {
Text(
text = replyTo.username,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
fontSize = 11.sp
)
Text(
text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
maxLines = 1
)
}
}
}
@ExperimentalTime @ExperimentalTime
private fun formatTime(timestamp: String): String { private fun formatTime(timestamp: String): String {
return try { return try {
val instant = Instant.parse(timestamp) Instant.parse(timestamp).toLocalDateTime(TimeZone.currentSystemDefault()).let {
val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) "${
val hour = localDateTime.hour.toString().padStart(2, '0') it.hour.toString().padStart(2, '0')
val minute = localDateTime.minute.toString().padStart(2, '0') }:${
"$hour:$minute" it.minute.toString().padStart(2, '0')
} catch (e: Exception) { }"
}
} catch (_: Exception) {
// Fallback: try parsing without timezone if it fails // Fallback: try parsing without timezone if it fails
try { try {
val parts = timestamp.split("T") val parts = timestamp.split("T")
@@ -254,9 +265,8 @@ private fun formatTime(timestamp: String): String {
} else { } else {
"" ""
} }
} catch (e2: Exception) { } catch (_: Exception) {
"" ""
} }
} }
} }
@@ -29,7 +29,7 @@ class PublicChatPanel(
scope.launch { scope.launch {
typingHandler.typingUsers.collect { users -> typingHandler.typingUsers.collect { users ->
Logger.d("PublicChatPanel", "Typing users updated in handler: ${users.map { it.username }}") Logger.d("PublicChatPanel", "Typing users updated in handler: ${users.map { it.username }}")
updateState { it.copy(typingUsers = users) } updateState { it.copy(typingUsers = users.filter { it.userId != currentUserId }) }
} }
} }
} }
@@ -58,7 +58,7 @@ class PublicChatPanel(
} }
setHasMoreMessages(false) // TODO: Implement has_more from API setHasMoreMessages(false) // TODO: Implement has_more from API
messagesLoaded = true messagesLoaded = true
} catch (e: Exception) { } catch (_: Exception) {
// Handle error // Handle error
} finally { } finally {
setLoading(false) setLoading(false)
@@ -84,7 +84,7 @@ class PublicChatPanel(
} }
} }
setHasMoreMessages(false) // TODO: Implement has_more from API setHasMoreMessages(false) // TODO: Implement has_more from API
} catch (e: Exception) { } catch (_: Exception) {
// Handle error // Handle error
} finally { } finally {
setLoadingMore(false) setLoadingMore(false)
@@ -4,9 +4,12 @@ import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -16,7 +19,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
@@ -63,67 +65,54 @@ private fun formatTypingText(typingUsers: List<String>): String {
@Composable @Composable
private fun TypingDots() { private fun TypingDots() {
val infiniteTransition = rememberInfiniteTransition(label = "typing_dots") val infiniteTransition = rememberInfiniteTransition(label = "typing_dots")
val duration = 1000
val initialScale = 1f
val targetScale = 1.3f
val dot1Alpha by infiniteTransition.animateFloat( // Функция-хелпер для создания анимации с задержкой
initialValue = 0.3f, @Composable
targetValue = 1f, fun animateDotScale(delay: Int) = infiniteTransition.animateFloat(
initialValue = initialScale,
targetValue = initialScale, // Возвращаемся в начало
animationSpec = infiniteRepeatable( animationSpec = infiniteRepeatable(
animation = keyframes { animation = keyframes {
durationMillis = 1400 durationMillis = duration
0.3f at 0 initialScale at delay // Начало подъема
1f at 200 targetScale at delay + 200 // Пик
0.3f at 400 initialScale at delay + 400 // Возврат
initialScale at duration // Удержание до конца цикла
} }
), ),
label = "dot1" label = "dot_scale"
) )
val dot2Alpha by infiniteTransition.animateFloat( val dot1Scale by animateDotScale(delay = 0)
initialValue = 0.3f, val dot2Scale by animateDotScale(delay = 200)
targetValue = 1f, val dot3Scale by animateDotScale(delay = 400)
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1400
0.3f at 200
1f at 400
0.3f at 600
}
),
label = "dot2"
)
val dot3Alpha by infiniteTransition.animateFloat( Row(
initialValue = 0.3f, verticalAlignment = Alignment.CenterVertically,
targetValue = 1f, horizontalArrangement = Arrangement.spacedBy(2.dp) // Вместо Spacer
animationSpec = infiniteRepeatable( ) {
animation = keyframes { Dot(scale = dot1Scale, maxScale = targetScale)
durationMillis = 1400 Dot(scale = dot2Scale, maxScale = targetScale)
0.3f at 400 Dot(scale = dot3Scale, maxScale = targetScale)
1f at 600
0.3f at 800
}
),
label = "dot3"
)
Row(verticalAlignment = Alignment.CenterVertically) {
Dot(alpha = dot1Alpha)
Spacer(modifier = Modifier.width(2.dp))
Dot(alpha = dot2Alpha)
Spacer(modifier = Modifier.width(2.dp))
Dot(alpha = dot3Alpha)
} }
} }
@Composable @Composable
private fun Dot(alpha: Float) { @Suppress("SameParameterValue")
Surface( private fun Dot(maxScale: Float, scale: Float) {
modifier = Modifier Box(
.width(4.dp) modifier = Modifier.size(4.dp * maxScale),
.height(4.dp) contentAlignment = Alignment.Center
.alpha(alpha), ) {
shape = CircleShape, Surface(
color = MaterialTheme.colorScheme.primary modifier = Modifier
) {} .width(4.dp * scale)
.height(4.dp * scale),
shape = CircleShape,
color = MaterialTheme.colorScheme.primary
) {}
}
} }
@@ -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
} }
@@ -40,13 +40,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.navigateAndWipeBackStack import com.pr0gramm3r101.utils.navigateAndWipeBackStack
import com.pr0gramm3r101.utils.storage.ServerConfigData
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketManager
import ru.fromchat.back import ru.fromchat.back
import ru.fromchat.core.ServerConfigData
import ru.fromchat.core.config.Config import ru.fromchat.core.config.Config
import ru.fromchat.https_enabled import ru.fromchat.https_enabled
import ru.fromchat.save_continue import ru.fromchat.save_continue
@@ -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
@@ -1,7 +1,8 @@
package ru.fromchat package ru.fromchat
import ru.fromchat.ui.App
import androidx.compose.ui.window.ComposeUIViewController import androidx.compose.ui.window.ComposeUIViewController
import ru.fromchat.ui.App
@Suppress("unused")
fun MainViewController() = ComposeUIViewController { App() } fun MainViewController() = ComposeUIViewController { App() }
@@ -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)
}
} }
+10 -2
View File
@@ -17,11 +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"
kotlinxSerializationJson = "1.9.0" multiplatformSettings = "1.3.0"
serialization = "2.3.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"
@@ -45,12 +48,16 @@ kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", versio
biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" } gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinxIoCore" } kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinxIoCore" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } 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" }
@@ -76,3 +83,4 @@ kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref
android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version = "2.3.0" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version = "2.3.0" }
android-library = { id = "com.android.library", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" }
+197 -95
View File
@@ -1,10 +1,10 @@
#!/bin/bash #!/bin/bash
# --- Настройка и окружение --- # --- Setup ---
set -e set -e
cd "$(dirname "$0")" cd "$(dirname "$0")"
# --- Обработка аргументов --- # --- Arguments ---
IS_PRERELEASE=false IS_PRERELEASE=false
show_help() { show_help() {
@@ -33,17 +33,17 @@ for arg in "$@"; do
esac esac
done done
# --- Конфигурация цветов и оформления --- # --- Colors ---
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
YELLOW='\033[1;33m' YELLOW='\033[1;33m'
BLUE='\033[0;34m' BLUE='\033[0;34m'
CYAN='\033[0;36m' CYAN='\033[0;36m'
MAGENTA='\033[0;35m' MAGENTA='\033[0;35m'
NC='\033[0m' # No Color NC='\033[0m'
BOLD='\033[1m' BOLD='\033[1m'
# --- Функции логирования --- # --- Logging ---
info() { echo -e "${BLUE}${NC} $1"; } info() { echo -e "${BLUE}${NC} $1"; }
success() { echo -e "${GREEN}${NC} $1"; } success() { echo -e "${GREEN}${NC} $1"; }
warning() { echo -e "${YELLOW}${NC} $1"; } warning() { echo -e "${YELLOW}${NC} $1"; }
@@ -52,47 +52,100 @@ step() { echo -e "\n${CYAN}${BOLD}→${NC} ${BOLD}$1${NC}"; }
substep() { echo -e " ${GREEN}${NC} $1"; } substep() { echo -e " ${GREEN}${NC} $1"; }
echo -e "${MAGENTA}${BOLD}🚀 FromChat KMP Release Pipeline${NC}" echo -e "${MAGENTA}${BOLD}🚀 FromChat KMP Release Pipeline${NC}"
[[ "$IS_PRERELEASE" == true ]] && info "Prerelease mode enabled via --pre flag"
# --- Настройка памяти для предотвращения OutOfMemory --- # --- 1. Version Input & Validation ---
export GRADLE_OPTS="-Dorg.gradle.jvmargs=-Xmx8g -Dkotlin.daemon.jvm.options=-Xmx8g" step "Configuration"
echo -en " ${GREEN}${NC} Release version: "
read -r USER_INPUT
# --- Переменные Git / Версии --- # Regex for x, x.y, or x.y.z
step "Metadata collection" VERSION_REGEX="^[0-9]+(\.[0-9]+)*$"
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.1") # shellcheck disable=SC2001
# shellcheck disable=SC2207 CLEAN_VERSION=$(echo "$USER_INPUT" | LC_ALL=C sed 's/^v//')
TAGS=($(git --no-pager tag --sort -v:refname | xargs))
PREVTAG=${TAGS[1]:-$TAG}
substep "Current tag: ${YELLOW}$TAG${NC}" if [[ ! $CLEAN_VERSION =~ $VERSION_REGEX ]]; then
substep "Previous tag: ${YELLOW}$PREVTAG${NC}" error "Error: Version must be in format x, x.y, or x.y.z (e.g. 1.2.3)"
exit 1
fi
BUILD_NUMBER=$(echo "$TAG" | sed 's/[^0-9]//g') TAG="v$CLEAN_VERSION"
VERSION_STR="$CLEAN_VERSION"
# shellcheck disable=SC2001
BUILD_NUMBER=$(echo "$VERSION_STR" | LC_ALL=C sed 's/[^0-9]//g')
[[ -z "$BUILD_NUMBER" ]] && BUILD_NUMBER=1 [[ -z "$BUILD_NUMBER" ]] && BUILD_NUMBER=1
# --- Сборка Android --- # Check if tag exists
build_android() { RECREATE_TAG=false
step "Building Android Release" if git rev-parse "$TAG" >/dev/null 2>&1; then
if ./gradlew :app:android:assembleRelease -PbuildNumber="$BUILD_NUMBER" -Dorg.gradle.jvmargs="-Xmx8g"; then echo -n " " && warning "Tag $TAG already exists."
APK_SRC=$(find . -name "*release.apk" | head -n 1) echo -en " ${GREEN}${NC} Delete it and move to the new commit? (y/N): "
read -r CONFIRM
if [[ -f "$APK_SRC" ]]; then if [[ "$CONFIRM" =~ ^[Yy]$ ]]; then
mkdir -p releases RECREATE_TAG=true
DISPLAY_NAME="FromChat-$TAG-android.apk"
cp "$APK_SRC" "releases/$DISPLAY_NAME"
ANDROID_ASSET="releases/$DISPLAY_NAME"
success "Android APK ready: ${CYAN}$DISPLAY_NAME${NC}"
else
error "Android APK not found after build"
exit 1
fi
else else
error "Android build failed" exit 0
exit 1 fi
fi
CURRENT_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --short HEAD)
STASH_MARKER="release_stash_$(date +%s)"
HAS_STASHED=false
restore_git_state() {
if [ "$HAS_STASHED" = true ]; then
STASH_ID=$(git stash list | grep "$STASH_MARKER" | head -n 1 | cut -d':' -f1)
if [[ -n "$STASH_ID" ]]; then
git stash pop "$STASH_ID" > /dev/null 2>&1 || true
fi
HAS_STASHED=false
fi
}
trap restore_git_state EXIT INT TERM
# --- 2. Git Cleanup & Safety ---
if [[ -n $(git status --short) ]]; then
git stash push --include-untracked -m "$STASH_MARKER" > /dev/null 2>&1
HAS_STASHED=true
fi
git fetch origin "$CURRENT_BRANCH" > /dev/null 2>&1
LOCAL_HASH=$(git rev-parse HEAD)
REMOTE_HASH=$(git rev-parse "origin/$CURRENT_BRANCH")
if [ "$LOCAL_HASH" != "$REMOTE_HASH" ]; then
if ! git merge-base --is-ancestor "$REMOTE_HASH" "$LOCAL_HASH"; then
error "Remote branch has commits you don't have. Please pull first."
exit 1
fi
fi
# --- Constants ---
BUILD_DIR="$(pwd)/build"
DESC_FILE="$BUILD_DIR/.release_desc.md"
RELEASES_DIR="$(pwd)/releases"
mkdir -p "$RELEASES_DIR"
export GRADLE_OPTS="-Dorg.gradle.jvmargs=-Xmx8g -Dkotlin.daemon.jvm.options=-Xmx8g"
# --- 3. Build Functions ---
build_android() {
step "Building Android Release"
if ./gradlew :app:android:assembleRelease; then
APK_SRC=$(find . -name "*release.apk" | head -n 1)
if [[ -f "$APK_SRC" ]]; then
DISPLAY_NAME="FromChat-$TAG-android.apk"
cp "$APK_SRC" "$RELEASES_DIR/$DISPLAY_NAME"
ANDROID_ASSET="$RELEASES_DIR/$DISPLAY_NAME"
success "Android APK ready: ${CYAN}$DISPLAY_NAME${NC}"
else
error "APK not found"; exit 1
fi
else
error "Android build failed"; exit 1
fi fi
} }
# --- Сборка iOS ---
build_ios() { build_ios() {
step "Building iOS Release" step "Building iOS Release"
if [[ "$OSTYPE" != "darwin"* ]]; then if [[ "$OSTYPE" != "darwin"* ]]; then
@@ -101,99 +154,148 @@ build_ios() {
fi fi
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer" export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
# shellcheck disable=SC2155
[[ ! -d "$DEVELOPER_DIR" ]] && export DEVELOPER_DIR=$(xcode-select -p) [[ ! -d "$DEVELOPER_DIR" ]] && export DEVELOPER_DIR=$(xcode-select -p)
IOS_PROJECT_DIR="app/ios" IOS_PROJECT_DIR="app/ios"
[[ ! -d "$IOS_PROJECT_DIR" ]] && IOS_PROJECT_DIR="iosApp" [[ ! -d "$IOS_PROJECT_DIR" ]] && IOS_PROJECT_DIR="iosApp"
substep "Setting iOS version to $BUILD_NUMBER..."
PLIST_PATH=$(find "$IOS_PROJECT_DIR" -name "Info.plist" | head -n 1) PLIST_PATH=$(find "$IOS_PROJECT_DIR" -name "Info.plist" | head -n 1)
if [[ -f "$PLIST_PATH" ]]; then if [[ -f "$PLIST_PATH" ]]; then
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST_PATH" || true /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST_PATH" || true
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${TAG#v}" "$PLIST_PATH" || true /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION_STR" "$PLIST_PATH" || true
fi fi
substep "Xcode Archiving (Unsigned)..." substep "Xcode Archiving..."
if xcodebuild -project "$IOS_PROJECT_DIR/iosApp.xcodeproj" \ rm -rf "$BUILD_DIR/ios" && mkdir -p "$BUILD_DIR/ios"
if ! xcodebuild -project "$IOS_PROJECT_DIR/iosApp.xcodeproj" \
-scheme iOS \ -scheme iOS \
-configuration Release \ -configuration Release \
-sdk iphoneos \ -sdk iphoneos \
-destination 'generic/platform=iOS' \ -destination 'generic/platform=iOS' \
-derivedDataPath build-output \ -derivedDataPath "$BUILD_DIR/ios" \
CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ENTITLEMENTS="" \ clean build > "$BUILD_DIR/ios/build.log" 2>&1
clean build > ios_build.log 2>&1; then then
error "iOS build failed. Check: $BUILD_DIR/ios/build.log"; exit 1
success "Xcode build successful."
else
error "Xcode build failed. Check ios_build.log"
exit 1
fi fi
substep "Packaging IPA for TrollStore..." substep "Packaging to ${CYAN}.ipa${NC}..."
mkdir -p Payload APP_BUNDLE_PATH=$(find "$BUILD_DIR/ios/Build/Products/Release-iphoneos" -name "*.app" -type d | head -n 1)
APP_PATH=$(find build-output -name "*.app" -type d | head -n 1)
if [[ -n "$APP_PATH" ]]; then if [[ -n "$APP_BUNDLE_PATH" ]]; then
cp -r "$APP_PATH" Payload/
IPA_NAME="FromChat-$TAG-ios-unsigned.ipa" IPA_NAME="FromChat-$TAG-ios-unsigned.ipa"
zip -r "releases/$IPA_NAME" Payload > /dev/null IPA_PATH="$RELEASES_DIR/$IPA_NAME"
rm -rf Payload
IOS_ASSET="releases/$IPA_NAME" # Create Payload structure inside build dir
success "iOS IPA ready: ${CYAN}$IPA_NAME${NC}" PAYLOAD_STAGE="$BUILD_DIR/ios/ipa_stage"
rm -rf "$PAYLOAD_STAGE" && mkdir -p "$PAYLOAD_STAGE/Payload"
# Use cp -R to dereference symlinks and copy actual files into the stage
# This is safe because it's only the final bundle
cp -R "$APP_BUNDLE_PATH" "$PAYLOAD_STAGE/Payload/"
# Zip from the stage directory
(cd "$PAYLOAD_STAGE" && zip -r "$IPA_PATH" Payload > /dev/null 2>&1)
# Cleanup stage
rm -rf "$PAYLOAD_STAGE"
if [[ -f "$IPA_PATH" ]]; then
IOS_ASSET="$IPA_PATH"
success "iOS IPA ready: ${CYAN}$IPA_NAME${NC}"
else
error "IPA generation failed (file not found in releases)"
exit 1
fi
else else
error "Could not find .app bundle" error "Could not find .app bundle"; exit 1
exit 1 fi
git checkout -- "$PLIST_PATH" > /dev/null 2>&1 || true
}
build_android
build_ios
# --- 4. Git Finalizing ---
IOS_PROJECT_DIR="app/ios"
[[ ! -d "$IOS_PROJECT_DIR" ]] && IOS_PROJECT_DIR="iosApp"
PLIST_PATH=$(find "$IOS_PROJECT_DIR" -name "Info.plist" | head -n 1)
if [[ -f "$PLIST_PATH" ]]; then
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST_PATH" || true
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION_STR" "$PLIST_PATH" || true
git add "$PLIST_PATH" > /dev/null 2>&1
if ! git diff --cached --quiet; then
if git commit --amend --no-edit > /dev/null 2>&1; then
substep "Info.plist updated (amend)."
else
error "Failed to amend commit."
fi
fi
fi
if [[ "$RECREATE_TAG" == true ]]; then
git tag -d "$TAG" > /dev/null 2>&1 || true
git push origin :refs/tags/"$TAG" > /dev/null 2>&1 || true
fi
if ! git rev-parse "$TAG" >/dev/null 2>&1; then
git tag -a "$TAG" -m "Release $TAG" > /dev/null 2>&1
fi
git push origin "$CURRENT_BRANCH" --force-with-lease --tags > /dev/null 2>&1
# --- 5. GitHub Release ---
prepare_description() {
echo -e "<!-- Release Description -->" > "$DESC_FILE"
nano "$DESC_FILE"
CLEAN_DESC=$(LC_ALL=C perl -0777 -pe 's/<!--.*?-->//gs' "$DESC_FILE" | LC_ALL=C sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
rm -f "$DESC_FILE"
if [[ -n "$CLEAN_DESC" ]]; then
echo "$CLEAN_DESC" > "$DESC_FILE"
NOTES_ARG=("-F" "$DESC_FILE")
else
NOTES_ARG=("--generate-notes")
fi fi
} }
# --- Публикация в GitHub ---
publish_github() { publish_github() {
step "GitHub Deployment" step "GitHub Release"
if ! command -v gh &> /dev/null; then if ! command -v gh &> /dev/null; then return; fi
warning "GitHub CLI (gh) not found. Skipping upload."
return
fi
substep "Pushing tags..."
git push --tags > /dev/null 2>&1 || true
# Определяем флаг пререлиза на основе тега ИЛИ аргумента --pre
prerelease_flag=""
if [[ "$IS_PRERELEASE" == true ]] || [[ "$TAG" == v*-pre* ]]; then
prerelease_flag="--prerelease"
fi
# Собираем существующие файлы в массив
ASSETS=() ASSETS=()
[[ -f "$ANDROID_ASSET" ]] && ASSETS+=("$ANDROID_ASSET") [[ -f "$ANDROID_ASSET" ]] && ASSETS+=("$ANDROID_ASSET")
[[ -f "$IOS_ASSET" ]] && ASSETS+=("$IOS_ASSET") [[ -f "$IOS_ASSET" ]] && ASSETS+=("$IOS_ASSET")
[ ${#ASSETS[@]} -eq 0 ] && return
if [ ${#ASSETS[@]} -eq 0 ]; then prepare_description
error "No assets found to upload. Check build logs."
return
fi
# Проверяем наличие релиза и создаем/обновляем
if gh release view "$TAG" >/dev/null 2>&1; then if gh release view "$TAG" >/dev/null 2>&1; then
substep "Updating existing release ${YELLOW}$TAG${NC}..." substep "Editing existing release..."
[[ -n "$prerelease_flag" ]] && gh release edit "$TAG" "$prerelease_flag" EDIT_ARGS=("--draft=false")
gh release upload "$TAG" "${ASSETS[@]}" --clobber [[ "$IS_PRERELEASE" == true ]] && EDIT_ARGS+=("--prerelease") || EDIT_ARGS+=("--prerelease=false")
[[ -f "$DESC_FILE" ]] && EDIT_ARGS+=("-F" "$DESC_FILE")
gh release edit "$TAG" "${EDIT_ARGS[@]}" 1> /dev/null
substep "Uploading files..."
gh release upload "$TAG" "${ASSETS[@]}" --clobber 1> /dev/null
else else
substep "Creating new release ${YELLOW}$TAG${NC}..." substep "Creating the release..."
gh release create "$TAG" \ PR_FLAG=""
--generate-notes \ [[ "$IS_PRERELEASE" == true ]] && PR_FLAG="--prerelease"
--notes-start-tag "${PREVTAG:-$TAG}" \ gh release create "$TAG" "${NOTES_ARG[@]}" $PR_FLAG --draft=false "${ASSETS[@]}" 1> /dev/null
$prerelease_flag \
"${ASSETS[@]}"
fi fi
success "Assets uploaded to GitHub Release" rm -f "$DESC_FILE"
success "Success!"
} }
# --- Основной процесс ---
mkdir -p releases
build_android
build_ios
publish_github publish_github
restore_git_state
trap - EXIT INT TERM
echo -e "\n${GREEN}${BOLD}✨ Release $TAG completed successfully!${NC}" echo -e "\n${GREEN}${BOLD}✨ Release $TAG completed successfully!${NC}"
+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)
} }
} }
} }
@@ -720,6 +720,7 @@ val ActivityInfo.launchIntent get() = Intent().apply {
flags = FLAG_ACTIVITY_NEW_TASK flags = FLAG_ACTIVITY_NEW_TASK
} }
@SuppressLint("QueryPermissionsNeeded")
@RequiresPermission(Manifest.permission.QUERY_ALL_PACKAGES) @RequiresPermission(Manifest.permission.QUERY_ALL_PACKAGES)
fun ActivityInfo.isLauncher(context: Context): Boolean { fun ActivityInfo.isLauncher(context: Context): Boolean {
with (context) { with (context) {
@@ -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))
@@ -93,4 +89,15 @@ class AndroidSettings(): Settings {
preferences.remove(stringSetPreferencesKey(key)) preferences.remove(stringSetPreferencesKey(key))
} }
} }
override suspend fun contains(key: String): Boolean {
return dataStore.data.map { preferences ->
preferences.contains(stringPreferencesKey(key)) ||
preferences.contains(intPreferencesKey(key)) ||
preferences.contains(longPreferencesKey(key)) ||
preferences.contains(floatPreferencesKey(key)) ||
preferences.contains(booleanPreferencesKey(key)) ||
preferences.contains(stringSetPreferencesKey(key))
}.first()
}
} }
@@ -1,128 +0,0 @@
package com.pr0gramm3r101.utils.settings
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
class AndroidAsyncSettings : AsyncSettings {
private val dataStore: DataStore<Preferences>
get() = DataStoreSingleton.dataStore
override suspend fun putString(key: String, value: String) {
dataStore.edit { preferences ->
preferences[stringPreferencesKey(key)] = value
}
}
override suspend fun getString(key: String, default: String): String {
return dataStore.data.map { preferences ->
preferences[stringPreferencesKey(key)] ?: default
}.first()
}
override suspend fun putInt(key: String, value: Int) {
dataStore.edit { preferences ->
preferences[intPreferencesKey(key)] = value
}
}
override suspend fun getInt(key: String, default: Int): Int {
return dataStore.data.map { preferences ->
preferences[intPreferencesKey(key)] ?: default
}.first()
}
override suspend fun putLong(key: String, value: Long) {
dataStore.edit { preferences ->
preferences[longPreferencesKey(key)] = value
}
}
override suspend fun getLong(key: String, default: Long): Long {
return dataStore.data.map { preferences ->
preferences[longPreferencesKey(key)] ?: default
}.first()
}
override suspend fun putFloat(key: String, value: Float) {
dataStore.edit { preferences ->
preferences[floatPreferencesKey(key)] = value
}
}
override suspend fun getFloat(key: String, default: Float): Float {
return dataStore.data.map { preferences ->
preferences[floatPreferencesKey(key)] ?: default
}.first()
}
override suspend fun putBoolean(key: String, value: Boolean) {
dataStore.edit { preferences ->
preferences[booleanPreferencesKey(key)] = value
}
}
override suspend fun getBoolean(key: String, default: Boolean): Boolean {
return dataStore.data.map { preferences ->
preferences[booleanPreferencesKey(key)] ?: default
}.first()
}
override suspend fun putStringSet(key: String, value: Set<String>) {
dataStore.edit { preferences ->
preferences[stringSetPreferencesKey(key)] = value
}
}
override suspend fun getStringSet(key: String, default: Set<String>): Set<String> {
return dataStore.data.map { preferences ->
preferences[stringSetPreferencesKey(key)] ?: default
}.first()
}
override suspend fun putStringList(key: String, value: List<String>) {
putStringSet(key, value.toSet())
}
override suspend fun getStringList(key: String, default: List<String>): List<String> {
return getStringSet(key, default.toSet()).toList()
}
override suspend fun remove(key: String) {
dataStore.edit { preferences ->
preferences.remove(stringPreferencesKey(key))
preferences.remove(intPreferencesKey(key))
preferences.remove(longPreferencesKey(key))
preferences.remove(floatPreferencesKey(key))
preferences.remove(booleanPreferencesKey(key))
preferences.remove(stringSetPreferencesKey(key))
}
}
override suspend fun contains(key: String): Boolean {
return dataStore.data.map { preferences ->
preferences.contains(stringPreferencesKey(key)) ||
preferences.contains(intPreferencesKey(key)) ||
preferences.contains(longPreferencesKey(key)) ||
preferences.contains(floatPreferencesKey(key)) ||
preferences.contains(booleanPreferencesKey(key)) ||
preferences.contains(stringSetPreferencesKey(key))
}.first()
}
override suspend fun clear() {
dataStore.edit { preferences ->
preferences.clear()
}
}
}
actual val asyncSettings: AsyncSettings = AndroidAsyncSettings()
@@ -12,6 +12,5 @@ import com.pr0gramm3r101.utils.UtilsLibrary
object DataStoreSingleton { object DataStoreSingleton {
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings") private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
val dataStore: DataStore<Preferences> val dataStore get() = UtilsLibrary.context.dataStore
get() = UtilsLibrary.context.dataStore
} }
@@ -1,3 +1,5 @@
package com.pr0gramm3r101.utils.settings package com.pr0gramm3r101.utils.settings
actual val settings: Settings get() = AndroidSettings() actual val settings: Settings get() = AndroidSettings()
actual val secureSettings: Settings
get() = AndroidSecureSettings()
@@ -1,5 +1,6 @@
package com.pr0gramm3r101.utils package com.pr0gramm3r101.utils
import androidx.compose.ui.Modifier
import androidx.navigation.NavController import androidx.navigation.NavController
/** /**
@@ -21,18 +22,22 @@ fun NavController.navigateAndWipeBackStack(route: String, launchSingleTop: Boole
val currentRoute = currentBackStackEntry?.destination?.route val currentRoute = currentBackStackEntry?.destination?.route
// Navigate to the new route, removing the current screen as well // Navigate to the new route, removing the current screen as well
if (currentRoute != null && currentRoute != route) { navigate(route) {
navigate(route) { if (currentRoute != null && currentRoute != route) {
popUpTo(currentRoute) { popUpTo(currentRoute) {
inclusive = true inclusive = true
} }
this.launchSingleTop = launchSingleTop
}
} else {
// Fallback: just navigate if current route is same or null
navigate(route) {
this.launchSingleTop = launchSingleTop
} }
this.launchSingleTop = launchSingleTop
} }
} }
inline fun Modifier.conditional(
condition: Boolean,
`else`: Modifier.(Modifier) -> Modifier = { Modifier },
`if`: Modifier.(Modifier) -> Modifier
) = if (condition) {
this + `if`(this)
} else {
this + `else`(this)
}
@@ -1,37 +0,0 @@
package com.pr0gramm3r101.utils.settings
/**
* Async version of Settings API using suspend functions
*/
interface AsyncSettings {
suspend fun putString(key: String, value: String)
suspend fun getString(key: String, default: String = ""): String
suspend fun putInt(key: String, value: Int)
suspend fun getInt(key: String, default: Int = 0): Int
suspend fun putLong(key: String, value: Long)
suspend fun getLong(key: String, default: Long = 0L): Long
suspend fun putFloat(key: String, value: Float)
suspend fun getFloat(key: String, default: Float = 0f): Float
suspend fun putBoolean(key: String, value: Boolean)
suspend fun getBoolean(key: String, default: Boolean = false): Boolean
suspend fun putStringSet(key: String, value: Set<String>)
suspend fun getStringSet(key: String, default: Set<String> = emptySet()): Set<String>
suspend fun putStringList(key: String, value: List<String>)
suspend fun getStringList(key: String, default: List<String> = emptyList()): List<String>
suspend fun remove(key: String)
suspend fun contains(key: String): Boolean
suspend fun clear()
}
/**
* Global async settings instance
*/
expect val asyncSettings: AsyncSettings
@@ -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,25 @@ 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)
suspend fun contains(key: String): Boolean
} }
@@ -1,3 +1,4 @@
package com.pr0gramm3r101.utils.settings package com.pr0gramm3r101.utils.settings
expect val settings: Settings expect val settings: Settings
expect val secureSettings: Settings
@@ -1,29 +0,0 @@
package com.pr0gramm3r101.utils.storage
/**
* Authentication token storage
*/
object AuthStorage {
private val storage = NamespacedStorage("auth")
suspend fun getToken(): String? {
val token = storage.getString("token")
return token.ifEmpty { null }
}
suspend fun setToken(token: String?) {
if (token != null) {
storage.putString("token", token)
} else {
storage.remove("token")
}
}
suspend fun clearToken() {
storage.remove("token")
}
suspend fun putString(key: String, value: String) = storage.putString(key, value)
suspend fun getString(key: String, default: String = ""): String = storage.getString(key, default)
suspend fun remove(key: String) = storage.remove(key)
}
@@ -1,52 +0,0 @@
package com.pr0gramm3r101.utils.storage
/**
* Server configuration data
*/
data class ServerConfigData(
val serverUrl: String,
val httpsEnabled: Boolean
)
/**
* Server configuration storage
*/
object ServerConfigStorage {
private val storage = NamespacedStorage("server_config")
private const val SERVER_URL_KEY = "server_url"
private const val HTTPS_ENABLED_KEY = "https_enabled"
suspend fun getServerUrl() = storage.getString(SERVER_URL_KEY).ifEmpty { null }
suspend fun setServerUrl(url: String) = storage.putString(SERVER_URL_KEY, url)
suspend fun getHttpsEnabled() =
if (storage.contains(HTTPS_ENABLED_KEY))
storage.getBoolean(HTTPS_ENABLED_KEY, true)
else null
suspend fun setHttpsEnabled(enabled: Boolean) {
storage.putBoolean(HTTPS_ENABLED_KEY, enabled)
}
suspend fun hasConfiguration() = getServerUrl() != null && getHttpsEnabled() != null
suspend fun getConfig(): ServerConfigData {
var url = getServerUrl()
var https = getHttpsEnabled()
if (url == null || https == null) {
url = "fromchat.ru"
https = true
saveConfig(ServerConfigData(url, https))
}
return ServerConfigData(url, https)
}
suspend fun saveConfig(config: ServerConfigData) {
setServerUrl(config.serverUrl)
setHttpsEnabled(config.httpsEnabled)
}
}
@@ -1,59 +0,0 @@
package com.pr0gramm3r101.utils.storage
import com.pr0gramm3r101.utils.settings.AsyncSettings
import com.pr0gramm3r101.utils.settings.asyncSettings
/**
* Generic key-value storage using AsyncSettings
*/
class Storage(private val settings: AsyncSettings = asyncSettings) {
suspend fun putString(key: String, value: String) = settings.putString(key, value)
suspend fun getString(key: String, default: String = ""): String = settings.getString(key, default)
suspend fun putInt(key: String, value: Int) = settings.putInt(key, value)
suspend fun getInt(key: String, default: Int = 0): Int = settings.getInt(key, default)
suspend fun putLong(key: String, value: Long) = settings.putLong(key, value)
suspend fun getLong(key: String, default: Long = 0L): Long = settings.getLong(key, default)
suspend fun putFloat(key: String, value: Float) = settings.putFloat(key, value)
suspend fun getFloat(key: String, default: Float = 0f): Float = settings.getFloat(key, default)
suspend fun putBoolean(key: String, value: Boolean) = settings.putBoolean(key, value)
suspend fun getBoolean(key: String, default: Boolean = false): Boolean = settings.getBoolean(key, default)
suspend fun putStringSet(key: String, value: Set<String>) = settings.putStringSet(key, value)
suspend fun getStringSet(key: String, default: Set<String> = emptySet()): Set<String> = settings.getStringSet(key, default)
suspend fun putStringList(key: String, value: List<String>) = settings.putStringList(key, value)
suspend fun getStringList(key: String, default: List<String> = emptyList()): List<String> = settings.getStringList(key, default)
suspend fun remove(key: String) = settings.remove(key)
suspend fun contains(key: String): Boolean = settings.contains(key)
suspend fun clear() = settings.clear()
}
/**
* Namespaced storage for specific features
*/
class NamespacedStorage(private val prefix: String, private val storage: Storage = Storage()) {
private fun key(name: String) = "$prefix:$name"
suspend fun putString(name: String, value: String) = storage.putString(key(name), value)
suspend fun getString(name: String, default: String = ""): String = storage.getString(key(name), default)
suspend fun putInt(name: String, value: Int) = storage.putInt(key(name), value)
suspend fun getInt(name: String, default: Int = 0): Int = storage.getInt(key(name), default)
suspend fun putLong(name: String, value: Long) = storage.putLong(key(name), value)
suspend fun getLong(name: String, default: Long = 0L): Long = storage.getLong(key(name), default)
suspend fun putFloat(name: String, value: Float) = storage.putFloat(key(name), value)
suspend fun getFloat(name: String, default: Float = 0f): Float = storage.getFloat(key(name), default)
suspend fun putBoolean(name: String, value: Boolean) = storage.putBoolean(key(name), value)
suspend fun getBoolean(name: String, default: Boolean = false): Boolean = storage.getBoolean(key(name), default)
suspend fun remove(name: String) = storage.remove(key(name))
suspend fun contains(name: String): Boolean = storage.contains(key(name))
}
@@ -1,99 +0,0 @@
package com.pr0gramm3r101.utils.settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import platform.Foundation.NSBundle
import platform.Foundation.NSUserDefaults
class IosAsyncSettings : AsyncSettings {
private val defaults: NSUserDefaults = NSUserDefaults.standardUserDefaults
override suspend fun putString(key: String, value: String): Unit = withContext(Dispatchers.Default) {
defaults.setObject(value, key)
defaults.synchronize()
}
override suspend fun getString(key: String, default: String): String = withContext(Dispatchers.Default) {
defaults.stringForKey(key) ?: default
}
override suspend fun putInt(key: String, value: Int): Unit = withContext(Dispatchers.Default) {
defaults.setInteger(value.toLong(), key)
defaults.synchronize()
}
override suspend fun getInt(key: String, default: Int): Int = withContext(Dispatchers.Default) {
defaults.integerForKey(key).toInt()
}
override suspend fun putLong(key: String, value: Long): Unit = withContext(Dispatchers.Default) {
defaults.setObject(value, key)
defaults.synchronize()
}
override suspend fun getLong(key: String, default: Long): Long = withContext(Dispatchers.Default) {
(defaults.objectForKey(key) as? platform.Foundation.NSNumber)?.longValue ?: default
}
override suspend fun putFloat(key: String, value: Float): Unit = withContext(Dispatchers.Default) {
defaults.setFloat(value, key)
defaults.synchronize()
}
override suspend fun getFloat(key: String, default: Float): Float = withContext(Dispatchers.Default) {
defaults.floatForKey(key)
}
override suspend fun putBoolean(key: String, value: Boolean): Unit = withContext(Dispatchers.Default) {
defaults.setBool(value, key)
defaults.synchronize()
}
override suspend fun getBoolean(key: String, default: Boolean): Boolean = withContext(Dispatchers.Default) {
if (defaults.objectForKey(key) != null) {
defaults.boolForKey(key)
} else {
default
}
}
override suspend fun putStringSet(key: String, value: Set<String>): Unit = withContext(Dispatchers.Default) {
defaults.setObject(value.toList(), key)
defaults.synchronize()
}
override suspend fun getStringSet(key: String, default: Set<String>): Set<String> = withContext(Dispatchers.Default) {
val list = defaults.objectForKey(key) as? List<*> ?: return@withContext default
list.filterIsInstance<String>().toSet()
}
override suspend fun putStringList(key: String, value: List<String>): Unit = withContext(Dispatchers.Default) {
defaults.setObject(value, key)
defaults.synchronize()
}
override suspend fun getStringList(key: String, default: List<String>): List<String> = withContext(Dispatchers.Default) {
val list = defaults.objectForKey(key) as? List<*> ?: return@withContext default
list.filterIsInstance<String>()
}
override suspend fun remove(key: String): Unit = withContext(Dispatchers.Default) {
defaults.removeObjectForKey(key)
defaults.synchronize()
}
override suspend fun contains(key: String): Boolean = withContext(Dispatchers.Default) {
defaults.objectForKey(key) != null
}
override suspend fun clear(): Unit = withContext(Dispatchers.Default) {
val bundleId = NSBundle.mainBundle.bundleIdentifier ?: ""
val domain = defaults.persistentDomainForName(bundleId)
domain?.keys?.forEach { key ->
defaults.removeObjectForKey(key as String)
}
defaults.synchronize()
}
}
actual val asyncSettings: AsyncSettings = IosAsyncSettings()
@@ -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,8 +1,10 @@
package com.pr0gramm3r101.utils.settings package com.pr0gramm3r101.utils.settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
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
@@ -42,4 +44,8 @@ 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.IO) {
defaults.objectForKey(key) != null
}
} }
@@ -1,3 +1,5 @@
package com.pr0gramm3r101.utils.settings package com.pr0gramm3r101.utils.settings
actual val settings: Settings get() = IosSettings() actual val settings: Settings get() = IosSettings()
actual val secureSettings: Settings
get() = IosSecureSettings()