diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d42e51b..5adafd2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,7 +10,8 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/Theme.FromChat"> + android:theme="@style/Theme.FromChat" + android:usesCleartextTraffic="true"> () + token = response.token + return response + } + + suspend fun register(request: RegisterRequest) { + // Endpoint exists in web: POST /register with {username,password,confirm_password} + http.post("https://fromchat.ru/api/register") { + contentType(ContentType.Application.Json) + setBody(request) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/fromchat/api/Models.kt b/app/src/main/java/ru/fromchat/api/Models.kt new file mode 100644 index 0000000..eb4ffcf --- /dev/null +++ b/app/src/main/java/ru/fromchat/api/Models.kt @@ -0,0 +1,60 @@ +package ru.fromchat.api + +import kotlinx.serialization.Serializable + +@Serializable +data class LoginRequest( + val username: String, + val password: String +) + +@Serializable +data class RegisterRequest( + val username: String, + val password: String, + val confirm_password: String +) + +@Serializable +data class ErrorResponse( + val message: String? = null +) + +@Serializable +data class User( + val id: Int, + val created_at: String, + val last_seen: String, + val online: Boolean, + val username: String, + val admin: Boolean? = null, + val bio: String? = null, + val profile_picture: String? = null +) + +@Serializable +data class LoginResponse( + val user: User, + val token: String +) + +// WebSocket types mirror frontend src/core/types.d.ts +@Serializable +data class WebSocketCredentials( + val scheme: String, + val credentials: String +) + +@Serializable +data class WebSocketError( + val code: Int, + val detail: String +) + +@Serializable +data class WebSocketMessage( + val type: String, + val credentials: WebSocketCredentials? = null, + val data: kotlinx.serialization.json.JsonElement? = null, + val error: WebSocketError? = null +) \ No newline at end of file diff --git a/app/src/main/java/ru/fromchat/api/WebSocketAPI.kt b/app/src/main/java/ru/fromchat/api/WebSocketAPI.kt new file mode 100644 index 0000000..d83e576 --- /dev/null +++ b/app/src/main/java/ru/fromchat/api/WebSocketAPI.kt @@ -0,0 +1,123 @@ +package ru.fromchat.api + +import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession +import io.ktor.client.plugins.websocket.webSocket +import io.ktor.http.HttpMethod +import io.ktor.http.URLProtocol +import io.ktor.http.encodedPath +import io.ktor.websocket.Frame +import io.ktor.websocket.readText +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import ru.fromchat.API_HOST + +object WebSocketManager { + // Config + private val scope = CoroutineScope(Dispatchers.IO) + private val json = Json { ignoreUnknownKeys = true } + + private val _messages = MutableSharedFlow(replay = 0, extraBufferCapacity = 64) + val messages = _messages.asSharedFlow() + + @Volatile + private var globalHandler: ((WebSocketMessage) -> Unit)? = null + + fun setGlobalMessageHandler(handler: ((WebSocketMessage) -> Unit)?) { + globalHandler = handler + } + + // State + @Volatile private var connecting: Boolean = false + @Volatile private var session: DefaultClientWebSocketSession? = null + + fun connect() { + if (connecting) return + connecting = true + + scope.launch { + while (isActive) { + try { + ApiClient.http.webSocket( + method = HttpMethod.Get, + host = API_HOST, + request = { + url { + protocol = URLProtocol.WSS + host = "fromchat.ru" + encodedPath = "/api/chat/ws" + } + } + ) { + session = this + + for (frame in incoming) { + val text = (frame as? Frame.Text)?.readText() ?: continue + try { + val msg = json.decodeFromString(WebSocketMessage.serializer(), text) + globalHandler?.invoke(msg) + _messages.emit(msg) + } catch (_: Throwable) { + // ignore malformed + } + } + } + } catch (_: Throwable) { + delay(3000) + } finally { + session = null + } + } + } + } + + suspend fun send(message: WebSocketMessage) { + val payload = json.encodeToString(WebSocketMessage.serializer(), message) + ApiClient.http.webSocket( + method = HttpMethod.Get, + host = API_HOST, + request = { + url { + protocol = URLProtocol.WSS + host = "fromchat.ru" + encodedPath = "/api/chat/ws" + } + } + ) { + send(Frame.Text(payload)) + } + } + + @OptIn(DelicateCoroutinesApi::class) + suspend fun request(message: WebSocketMessage, timeoutMs: Long = 10_000): WebSocketMessage? { + val ch = Channel(capacity = 1) + val handler: (WebSocketMessage) -> Unit = { + ch.trySend(it) + } + setGlobalMessageHandler(handler) + + return try { + send(message) + withTimeout(timeoutMs) { ch.receive() } + } catch (_: TimeoutCancellationException) { + null + } finally { + setGlobalMessageHandler(null) + ch.close() + } + } + + fun shutdown() { + scope.cancel() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/fromchat/ui/App.kt b/app/src/main/java/ru/fromchat/ui/App.kt index fdf326a..b57dcff 100644 --- a/app/src/main/java/ru/fromchat/ui/App.kt +++ b/app/src/main/java/ru/fromchat/ui/App.kt @@ -1,10 +1,23 @@ package ru.fromchat.ui import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import ru.fromchat.ui.auth.LoginScreen +import ru.fromchat.ui.auth.RegisterScreen +import ru.fromchat.ui.chat.ChatScreen @Composable fun App() { FromChatTheme { - + val (route, setRoute) = remember { mutableStateOf("login") } + when (route) { + "login" -> LoginScreen( + onLoginSuccess = { setRoute("chat") }, + onNavigateToRegister = { setRoute("register") } + ) + "register" -> RegisterScreen(onRegistered = { setRoute("login") }) + "chat" -> ChatScreen() + } } } \ No newline at end of file diff --git a/app/src/main/java/ru/fromchat/ui/auth/LoginScreen.kt b/app/src/main/java/ru/fromchat/ui/auth/LoginScreen.kt new file mode 100644 index 0000000..624ee9e --- /dev/null +++ b/app/src/main/java/ru/fromchat/ui/auth/LoginScreen.kt @@ -0,0 +1,103 @@ +package ru.fromchat.ui.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Login +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import ru.fromchat.api.ApiClient +import ru.fromchat.api.LoginRequest + +@Composable +fun LoginScreen( + onLoginSuccess: () -> Unit, + onNavigateToRegister: () -> Unit +) { + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var alert by remember { mutableStateOf(null) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Card(modifier = Modifier.padding(16.dp)) { + Column(modifier = Modifier.padding(16.dp)) { + RowHeader() + + if (alert != null) { + Text(text = alert!!, color = MaterialTheme.colorScheme.error) + Spacer(Modifier.height(8.dp)) + } + + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text("Имя пользователя") }, + singleLine = true + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Пароль") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation() + ) + + Spacer(Modifier.height(12.dp)) + Button(onClick = { + if (username.isBlank() || password.isBlank()) { + alert = "Пожалуйста, заполните все поля" + return@Button + } + CoroutineScope(Dispatchers.IO).launch { + try { + ApiClient.login(LoginRequest(username, password)) + onLoginSuccess() + } catch (e: Exception) { + alert = "Неверное имя пользователя или пароль" + } + } + }) { Text("Войти") } + + Spacer(Modifier.height(8.dp)) + Button(onClick = onNavigateToRegister) { Text("Зарегистрируйтесь") } + } + } + } +} + +@Composable +private fun RowHeader() { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(Icons.AutoMirrored.Filled.Login, contentDescription = null) + Text("Добро пожаловать!") + Text("Войдите в свой аккаунт", style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(8.dp)) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/fromchat/ui/auth/RegisterScreen.kt b/app/src/main/java/ru/fromchat/ui/auth/RegisterScreen.kt new file mode 100644 index 0000000..ae45386 --- /dev/null +++ b/app/src/main/java/ru/fromchat/ui/auth/RegisterScreen.kt @@ -0,0 +1,115 @@ +package ru.fromchat.ui.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import ru.fromchat.api.ApiClient +import ru.fromchat.api.RegisterRequest + +@Composable +fun RegisterScreen( + onRegistered: () -> Unit +) { + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var confirmPassword by remember { mutableStateOf("") } + var alert by remember { mutableStateOf(null) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Card(modifier = Modifier.padding(16.dp)) { + Column(modifier = Modifier.padding(16.dp)) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(Icons.Default.PersonAdd, contentDescription = null) + Text("Регистрация") + Text("Создайте новый аккаунт", style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(8.dp)) + } + + if (alert != null) { + Text(text = alert!!, color = MaterialTheme.colorScheme.error) + Spacer(Modifier.height(8.dp)) + } + + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text("Имя пользователя") }, + singleLine = true + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Пароль") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation() + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = confirmPassword, + onValueChange = { confirmPassword = it }, + label = { Text("Подтвердите пароль") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation() + ) + + Spacer(Modifier.height(12.dp)) + Button(onClick = { + if (username.isBlank() || password.isBlank() || confirmPassword.isBlank()) { + alert = "Пожалуйста, заполните все поля" + return@Button + } + if (password != confirmPassword) { + alert = "Пароли не совпадают" + return@Button + } + if (username.length !in 3..20) { + alert = "Имя пользователя должно быть от 3 до 20 символов" + return@Button + } + if (password.length !in 5..50) { + alert = "Пароль должен быть от 5 до 50 символов" + return@Button + } + CoroutineScope(Dispatchers.IO).launch { + try { + ApiClient.register(RegisterRequest(username, password, confirmPassword)) + onRegistered() + } catch (e: Exception) { + alert = "Ошибка при регистрации" + } + } + }) { Text("Зарегистрироваться") } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/fromchat/ui/chat/BaseChat.kt b/app/src/main/java/ru/fromchat/ui/chat/BaseChat.kt new file mode 100644 index 0000000..b0cda8f --- /dev/null +++ b/app/src/main/java/ru/fromchat/ui/chat/BaseChat.kt @@ -0,0 +1,86 @@ +package ru.fromchat.ui.chat + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import ru.fromchat.api.WebSocketManager +import ru.fromchat.api.WebSocketMessage + +data class ChatMessage(val author: String, val content: String) + +@OptIn(DelicateCoroutinesApi::class) +@Composable +fun BaseChat( + chatTitle: String +) { + val messages = remember { mutableStateListOf() } + var composing by remember { mutableStateOf("") } + + LaunchedEffect(Unit) { + WebSocketManager.connect() + WebSocketManager.setGlobalMessageHandler { msg -> + // Placeholder: append raw data content if present + val text = msg.data?.toString() ?: return@setGlobalMessageHandler + messages.add(ChatMessage(author = "Server", content = text)) + } + } + + Surface(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize().padding(8.dp)) { + Text(chatTitle) + + LazyColumn(modifier = Modifier.weight(1f)) { + items(messages) { m -> + Text("${m.author}: ${m.content}") + } + } + + Row { + OutlinedTextField( + value = composing, + onValueChange = { composing = it }, + modifier = Modifier.weight(1f), + label = { Text("Сообщение") } + ) + Button(onClick = { + if (composing.isNotBlank()) { + // Note: server schema for public chat messages is not specified in the repo. + // We send a generic WebSocketMessage to match frontend type contract. + val payload = WebSocketMessage( + type = "public_chat_message", + data = buildJsonObject { + put("content", JsonPrimitive(composing)) + put("chatName", JsonPrimitive(chatTitle)) + } + ) + // Fire and forget + GlobalScope.launch { WebSocketManager.send(payload) } + messages.add(ChatMessage(author = "Вы", content = composing)) + composing = "" + } + }) { Text("Отправить") } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/fromchat/ui/chat/ChatScreen.kt b/app/src/main/java/ru/fromchat/ui/chat/ChatScreen.kt new file mode 100644 index 0000000..1707d11 --- /dev/null +++ b/app/src/main/java/ru/fromchat/ui/chat/ChatScreen.kt @@ -0,0 +1,74 @@ +package ru.fromchat.ui.chat + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.filled.Contacts +import androidx.compose.material.icons.filled.Mail +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +enum class ChatTab { CHATS, CONTACTS, DMS } + +@Composable +fun ChatScreen() { + var selectedTab by remember { mutableStateOf(ChatTab.CHATS) } + var activeChat by remember { mutableStateOf(null) } + + Scaffold( + bottomBar = { + NavigationBar { + NavigationBarItem( + selected = selectedTab == ChatTab.CHATS, + onClick = { selectedTab = ChatTab.CHATS }, + label = { Text("Чаты") }, + icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) } + ) + NavigationBarItem( + selected = selectedTab == ChatTab.CONTACTS, + onClick = { selectedTab = ChatTab.CONTACTS }, + label = { Text("Контакты") }, + icon = { Icon(Icons.Filled.Contacts, contentDescription = null) } + ) + NavigationBarItem( + selected = selectedTab == ChatTab.DMS, + onClick = { selectedTab = ChatTab.DMS }, + label = { Text("ЛС") }, + icon = { Icon(Icons.Filled.Mail, contentDescription = null) } + ) + } + } + ) { paddingValues -> + Column(modifier = Modifier.fillMaxSize().padding(paddingValues).padding(12.dp)) { + when (selectedTab) { + ChatTab.CHATS -> { + if (activeChat == null) { + Text("Общий чат", modifier = Modifier.clickable { activeChat = "Общий чат" }.padding(8.dp)) + Text("Общий чат 2", modifier = Modifier.clickable { activeChat = "Общий чат 2" }.padding(8.dp)) + } else { + BaseChat(chatTitle = activeChat!!) + } + } + ChatTab.CONTACTS -> { + Text("Скоро будет...") + } + ChatTab.DMS -> { + Text("Скоро будет...") + } + } + } + } +} \ No newline at end of file