From 431e3755c610cf1270cafb7b6bdb18a29e63702d Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 16 Jan 2026 23:06:41 +0500 Subject: [PATCH] feat: Integrate Firebase Cloud Messaging for push notifications; add service to handle token and message reception --- FCM_SETUP.md | 306 ++++++++++++ FCM_TODO.md | 207 ++++++++ app/build.gradle.kts | 6 + app/google-services.json.example | 29 ++ app/src/main/AndroidManifest.xml | 21 + .../com/rosetta/messenger/MainActivity.kt | 79 ++- .../com/rosetta/messenger/network/Packets.kt | 30 ++ .../push/RosettaFirebaseMessagingService.kt | 210 ++++++++ .../messenger/ui/chats/ChatDetailScreen.kt | 96 +--- .../messenger/ui/chats/ChatsListScreen.kt | 459 +++++++++++++----- .../messenger/ui/chats/SearchScreen.kt | 30 +- .../com/rosetta/messenger/ui/theme/Type.kt | 68 ++- app/src/main/res/values/colors.xml | 9 + build.gradle.kts | 1 + 14 files changed, 1317 insertions(+), 234 deletions(-) create mode 100644 FCM_SETUP.md create mode 100644 FCM_TODO.md create mode 100644 app/google-services.json.example create mode 100644 app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt create mode 100644 app/src/main/res/values/colors.xml diff --git a/FCM_SETUP.md b/FCM_SETUP.md new file mode 100644 index 0000000..bcbb88e --- /dev/null +++ b/FCM_SETUP.md @@ -0,0 +1,306 @@ +# Firebase Cloud Messaging (FCM) Integration + +## Обзор + +Android приложение использует **Firebase Cloud Messaging (FCM)** для push-уведомлений о новых сообщениях. + +## Архитектура + +### Клиент (Android) + +1. **При старте приложения** (`MainActivity.kt`): + + - Инициализируется Firebase + - Получается FCM токен + - Токен отправляется на сервер через протокол + +2. **При получении нового токена** (`RosettaFirebaseMessagingService.kt`): + + - Вызывается `onNewToken()` + - Новый токен отправляется на сервер + +3. **При получении push-уведомления** (`RosettaFirebaseMessagingService.kt`): + - Вызывается `onMessageReceived()` + - Показывается системное уведомление + - При клике открывается соответствующий чат + +### Протокол + +**Packet ID: 0x0A - PacketPushToken** + +Формат пакета: + +``` +[PacketID: 0x0A (2 bytes)] +[privateKey: String] +[publicKey: String] +[pushToken: String] +[platform: String] // "android" или "ios" +``` + +### Сервер + +Сервер должен: + +1. Получить `PacketPushToken` от клиента +2. Сохранить в БД связку: `publicKey -> fcmToken` +3. При получении нового сообщения для пользователя: + - Найти FCM токен по `publicKey` получателя + - Отправить push через FCM HTTP API + +## Настройка Firebase + +### 1. Создать Firebase проект + +1. Перейти на [Firebase Console](https://console.firebase.google.com/) +2. Создать новый проект или выбрать существующий +3. Добавить Android приложение + +### 2. Настройка Android app + +**Package name:** `com.rosetta.messenger` + +1. В Firebase Console → Project Settings → General +2. Скачать `google-services.json` +3. Поместить файл в `rosetta-android/app/google-services.json` + +### 3. Структура google-services.json + +```json +{ + "project_info": { + "project_number": "YOUR_PROJECT_NUMBER", + "project_id": "your-project-id", + "storage_bucket": "your-project-id.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:YOUR_PROJECT_NUMBER:android:xxxxx", + "android_client_info": { + "package_name": "com.rosetta.messenger" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "YOUR_API_KEY" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} +``` + +### 4. Получить Server Key + +1. Firebase Console → Project Settings → Cloud Messaging +2. Скопировать **Server Key** (Legacy) +3. Использовать на сервере для отправки уведомлений + +## Отправка Push-уведомлений с сервера + +### FCM HTTP v1 API (рекомендуется) + +```bash +POST https://fcm.googleapis.com/v1/projects/YOUR_PROJECT_ID/messages:send +Authorization: Bearer YOUR_ACCESS_TOKEN +Content-Type: application/json + +{ + "message": { + "token": "FCM_DEVICE_TOKEN_FROM_CLIENT", + "notification": { + "title": "Имя отправителя", + "body": "Текст сообщения" + }, + "data": { + "type": "new_message", + "sender_public_key": "SENDER_PUBLIC_KEY", + "sender_name": "Имя отправителя", + "message_preview": "Текст сообщения" + }, + "android": { + "priority": "HIGH", + "notification": { + "channel_id": "messages", + "sound": "default", + "notification_priority": "PRIORITY_HIGH" + } + } + } +} +``` + +### Legacy FCM API (проще для начала) + +```bash +POST https://fcm.googleapis.com/fcm/send +Authorization: key=YOUR_SERVER_KEY +Content-Type: application/json + +{ + "to": "FCM_DEVICE_TOKEN_FROM_CLIENT", + "notification": { + "title": "Имя отправителя", + "body": "Текст сообщения", + "sound": "default" + }, + "data": { + "type": "new_message", + "sender_public_key": "SENDER_PUBLIC_KEY", + "sender_name": "Имя отправителя", + "message_preview": "Текст сообщения" + }, + "priority": "high", + "android": { + "priority": "HIGH", + "notification": { + "channel_id": "messages" + } + } +} +``` + +## Пример серверной логики (Node.js) + +```javascript +const admin = require("firebase-admin"); + +// Инициализация Firebase Admin SDK +const serviceAccount = require("./path/to/serviceAccountKey.json"); +admin.initializeApp({ + credential: admin.credential.cert(serviceAccount), +}); + +// База данных с токенами (пример) +const userTokens = new Map(); // publicKey -> fcmToken + +// Обработка PacketPushToken (0x0A) +function handlePushToken(packet) { + const { publicKey, pushToken, platform } = packet; + + console.log(`📱 Saving FCM token for user ${publicKey.slice(0, 10)}...`); + userTokens.set(publicKey, pushToken); +} + +// Отправка push-уведомления +async function sendPushNotification( + recipientPublicKey, + senderPublicKey, + senderName, + messageText +) { + const fcmToken = userTokens.get(recipientPublicKey); + + if (!fcmToken) { + console.log("⚠️ No FCM token for user"); + return; + } + + const message = { + token: fcmToken, + notification: { + title: senderName, + body: messageText, + }, + data: { + type: "new_message", + sender_public_key: senderPublicKey, + sender_name: senderName, + message_preview: messageText, + }, + android: { + priority: "HIGH", + notification: { + channelId: "messages", + sound: "default", + priority: "PRIORITY_HIGH", + }, + }, + }; + + try { + const response = await admin.messaging().send(message); + console.log("✅ Push notification sent:", response); + } catch (error) { + console.error("❌ Error sending push:", error); + + // Если токен невалиден - удаляем из БД + if ( + error.code === "messaging/invalid-registration-token" || + error.code === "messaging/registration-token-not-registered" + ) { + userTokens.delete(recipientPublicKey); + } + } +} + +// Когда приходит PacketMessage +function handleNewMessage(packet) { + const { fromPublicKey, toPublicKey, content } = packet; + + // Отправляем push получателю (если он не онлайн) + sendPushNotification(toPublicKey, fromPublicKey, "User", "New message"); +} +``` + +## Тестирование + +### 1. Проверка токена + +```kotlin +// В логах Android Studio должно быть: +🔔 FCM token: XXXXXXXXXXXX... +``` + +### 2. Тест через Firebase Console + +1. Firebase Console → Cloud Messaging +2. Send test message +3. Вставить FCM токен из логов +4. Отправить + +### 3. Проверка формата пакета + +``` +Packet 0x0A должен содержать: +- PacketID: 0x0A (10 в decimal) +- privateKey: хеш приватного ключа +- publicKey: публичный ключ пользователя +- pushToken: FCM токен устройства +- platform: "android" +``` + +## Troubleshooting + +### Токен не получается + +1. Проверить `google-services.json` в `app/google-services.json` +2. Проверить package name совпадает: `com.rosetta.messenger` +3. Rebuild проект +4. Проверить Google Play Services на устройстве + +### Уведомления не приходят + +1. Проверить разрешение на уведомления (Android Settings) +2. Проверить Server Key на сервере +3. Проверить FCM токен актуален +4. Проверить формат JSON в FCM API запросе + +### Уведомления приходят, но не открывают чат + +Проверить data payload содержит `sender_public_key` + +## Полезные ссылки + +- [Firebase Console](https://console.firebase.google.com/) +- [FCM Documentation](https://firebase.google.com/docs/cloud-messaging) +- [Android Setup Guide](https://firebase.google.com/docs/cloud-messaging/android/client) +- [FCM HTTP Protocol](https://firebase.google.com/docs/cloud-messaging/http-server-ref) diff --git a/FCM_TODO.md b/FCM_TODO.md new file mode 100644 index 0000000..b5d3bb6 --- /dev/null +++ b/FCM_TODO.md @@ -0,0 +1,207 @@ +# 🔔 Что еще нужно для полной интеграции FCM (Push-уведомления) + +## ✅ Уже реализовано + +1. **Android клиент:** + + - ✅ Firebase SDK добавлен в dependencies + - ✅ `RosettaFirebaseMessagingService` - обработка уведомлений + - ✅ `MainActivity` - инициализация Firebase и отправка токена + - ✅ Packet `PacketPushToken` (0x0A) для отправки токена на сервер + - ✅ Разрешения `POST_NOTIFICATIONS` в AndroidManifest + - ✅ Сервис зарегистрирован в манифесте + +2. **Документация:** + - ✅ `FCM_SETUP.md` с полной архитектурой и примерами + - ✅ `google-services.json.example` - пример конфига + +## ⚠️ Нужно сделать + +### 1. Firebase Console Setup + +1. **Получить google-services.json:** + + ```bash + # 1. Зайти на https://console.firebase.google.com/ + # 2. Создать/выбрать проект + # 3. Добавить Android приложение + # 4. Указать package name: com.rosetta.messenger + # 5. Скачать google-services.json + # 6. Положить в rosetta-android/app/ + ``` + +2. **Важно:** Файл `google-services.json` должен быть в gitignore (уже есть пример .example) + +### 2. Серверная часть + +Нужно реализовать на сервере: + +#### 2.1 Обработка PacketPushToken (0x0A) + +```kotlin +// Сервер должен сохранить FCM токен для пользователя +when (packetType) { + 0x0A -> { // PacketPushToken + val pushToken = PacketPushToken.deserialize(stream) + + // Сохранить в базе данных: + // user_fcm_tokens: + // - public_key (primary key) + // - fcm_token (string) + // - platform ("android") + // - updated_at (timestamp) + + saveFcmToken( + publicKey = pushToken.publicKey, + fcmToken = pushToken.pushToken, + platform = pushToken.platform + ) + } +} +``` + +#### 2.2 Отправка Push-уведомлений + +Когда приходит новое сообщение для пользователя: + +```kotlin +// Псевдокод для сервера +fun onNewMessage(toPublicKey: String, fromPublicKey: String, message: String) { + // 1. Получить FCM токен получателя + val fcmToken = getFcmToken(toPublicKey) ?: return + + // 2. Отправить уведомление через Firebase Admin SDK + val notification = Message.builder() + .setToken(fcmToken) + .setNotification( + Notification.builder() + .setTitle(getSenderName(fromPublicKey)) + .setBody(message) + .build() + ) + .putData("sender_public_key", fromPublicKey) + .putData("message", message) + .build() + + FirebaseMessaging.getInstance().send(notification) +} +``` + +#### 2.3 Firebase Admin SDK (Node.js пример) + +```javascript +// На сервере установить: +// npm install firebase-admin + +const admin = require("firebase-admin"); +const serviceAccount = require("./serviceAccountKey.json"); + +admin.initializeApp({ + credential: admin.credential.cert(serviceAccount), +}); + +async function sendPushNotification(fcmToken, senderName, messageText) { + const message = { + token: fcmToken, + notification: { + title: senderName, + body: messageText, + }, + data: { + sender_public_key: senderPublicKey, + message: messageText, + }, + android: { + priority: "high", + notification: { + sound: "default", + channelId: "rosetta_messages", + }, + }, + }; + + try { + const response = await admin.messaging().send(message); + console.log("✅ Push sent:", response); + } catch (error) { + console.error("❌ Push failed:", error); + } +} +``` + +### 3. Тестирование + +#### 3.1 Проверка получения токена + +```bash +# После запуска приложения проверить в logcat: +adb logcat -s RosettaFCM +# Должно быть: 🔔 FCM token: ... +``` + +#### 3.2 Отправка тестового уведомления + +Через Firebase Console: + +1. Зайти в Firebase Console → Cloud Messaging +2. Создать тестовое уведомление +3. Указать FCM token из logcat +4. Отправить + +#### 3.3 Проверка работы сервера + +1. Запустить приложение +2. Убедиться что `PacketPushToken` отправился на сервер (логи протокола) +3. Отправить сообщение с другого аккаунта +4. Проверить что пришло push-уведомление + +### 4. Оптимизации (опционально) + +1. **Обработка обновления токена:** + + - FCM токен может меняться + - `onNewToken()` в `RosettaFirebaseMessagingService` уже обрабатывает это + - Сервер должен обновлять токен в БД + +2. **Разные типы уведомлений:** + + ```kotlin + // Можно добавить в PacketPushToken поле notification_settings + data class NotificationSettings( + val showPreview: Boolean = true, // Показывать текст сообщения + val vibrate: Boolean = true, + val sound: Boolean = true + ) + ``` + +3. **Проверка валидности токена:** + - Сервер может проверять что токен валиден через Firebase Admin API + - Удалять невалидные токены из БД + +## 📝 Чеклист готовности + +- [x] Android: Firebase SDK добавлен +- [x] Android: FCM Service создан +- [x] Android: PacketPushToken добавлен +- [x] Android: MainActivity отправляет токен +- [ ] **Firebase: google-services.json получен и добавлен** +- [ ] **Сервер: Обработка PacketPushToken** +- [ ] **Сервер: Firebase Admin SDK настроен** +- [ ] **Сервер: Отправка push при новых сообщениях** +- [ ] Тестирование: Получение токена работает +- [ ] Тестирование: Push-уведомления приходят + +## 🚀 Следующий шаг + +**Сейчас нужно:** + +1. Получить `google-services.json` из Firebase Console +2. Добавить его в `rosetta-android/app/google-services.json` +3. Реализовать серверную часть (обработка PacketPushToken + отправка push) +4. Протестировать end-to-end + +## 📚 Полезные ссылки + +- [FCM Android Setup](https://firebase.google.com/docs/cloud-messaging/android/client) +- [Firebase Admin SDK](https://firebase.google.com/docs/admin/setup) +- [FCM Server Protocols](https://firebase.google.com/docs/cloud-messaging/server) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ab0943d..77232e4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("kotlin-kapt") + id("com.google.gms.google-services") } android { @@ -110,6 +111,11 @@ dependencies { // Baseline Profiles for startup performance implementation("androidx.profileinstaller:profileinstaller:1.3.1") + // Firebase Cloud Messaging + implementation(platform("com.google.firebase:firebase-bom:32.7.0")) + implementation("com.google.firebase:firebase-messaging-ktx") + implementation("com.google.firebase:firebase-analytics-ktx") + // Testing dependencies testImplementation("junit:junit:4.13.2") testImplementation("io.mockk:mockk:1.13.8") diff --git a/app/google-services.json.example b/app/google-services.json.example new file mode 100644 index 0000000..f29182f --- /dev/null +++ b/app/google-services.json.example @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "123456789012", + "project_id": "rosetta-messenger", + "storage_bucket": "rosetta-messenger.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:123456789012:android:abcdef1234567890", + "android_client_info": { + "package_name": "com.rosetta.messenger" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 609ae69..f02b2ab 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 7fcf086..82d5943 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -1,6 +1,7 @@ package com.rosetta.messenger import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -27,10 +28,14 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.google.firebase.FirebaseApp +import com.google.firebase.messaging.FirebaseMessaging +import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.data.RecentSearchesManager +import com.rosetta.messenger.network.PacketPushToken import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.ui.auth.AccountInfo import com.rosetta.messenger.ui.auth.AuthFlow @@ -49,6 +54,10 @@ class MainActivity : ComponentActivity() { private lateinit var preferencesManager: PreferencesManager private lateinit var accountManager: AccountManager + companion object { + private const val TAG = "MainActivity" + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() @@ -60,6 +69,9 @@ class MainActivity : ComponentActivity() { // 🔥 Инициализируем ProtocolManager для обработки онлайн статусов ProtocolManager.initialize(this) + // 🔔 Инициализируем Firebase для push-уведомлений + initializeFirebase() + // 🚀 Предзагружаем эмодзи в фоне для мгновенного открытия пикера // Используем новый оптимизированный кэш OptimizedEmojiCache.preload(this) @@ -153,7 +165,7 @@ class MainActivity : ComponentActivity() { } ) } - "auth_new", "auth_new", "auth_unlock" -> { + "auth_new", "auth_unlock" -> { AuthFlow( isDarkTheme = isDarkTheme, hasExistingAccount = screen == "auth_unlock", @@ -223,6 +235,47 @@ class MainActivity : ComponentActivity() { } } } + + /** + * 🔔 Инициализация Firebase Cloud Messaging + */ + private fun initializeFirebase() { + try { + // Инициализируем Firebase + FirebaseApp.initializeApp(this) + + // Получаем FCM токен + FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> + if (!task.isSuccessful) { + Log.e(TAG, "❌ Failed to get FCM token", task.exception) + return@addOnCompleteListener + } + + val token = task.result + Log.d(TAG, "🔔 FCM token (short): ${token?.take(20)}...") + Log.d(TAG, "🔔 FCM token (FULL): $token") + + // Сохраняем токен локально + token?.let { saveFcmToken(it) } + + // TODO: Отправляем токен на сервер если аккаунт залогинен + // token?.let { sendFcmTokenToServer(it) } + } + + Log.d(TAG, "✅ Firebase initialized successfully") + } catch (e: Exception) { + Log.e(TAG, "❌ Error initializing Firebase", e) + } + } + + /** + * Сохранить FCM токен в SharedPreferences + */ + private fun saveFcmToken(token: String) { + val prefs = getSharedPreferences("rosetta_prefs", MODE_PRIVATE) + prefs.edit().putString("fcm_token", token).apply() + Log.d(TAG, "💾 FCM token saved locally") + } } @Composable @@ -333,12 +386,12 @@ fun MainScreen( } }, label = "screenNavigation" - ) { (user, isSearchOpen, _) -> + ) { (currentUser, isSearchOpen, _) -> when { - user != null -> { + currentUser != null -> { // Экран чата ChatDetailScreen( - user = user, + user = currentUser, currentUserPublicKey = accountPublicKey, currentUserPrivateKey = accountPrivateKey, isDarkTheme = isDarkTheme, @@ -365,9 +418,9 @@ fun MainScreen( isDarkTheme = isDarkTheme, protocolState = protocolState, onBackClick = { showSearchScreen = false }, - onUserSelect = { user -> + onUserSelect = { selectedSearchUser -> showSearchScreen = false - selectedUser = user + selectedUser = selectedSearchUser } ) } @@ -394,7 +447,14 @@ fun MainScreen( // TODO: Navigate to calls }, onSavedMessagesClick = { - // TODO: Navigate to saved messages + // Открываем чат с самим собой (Saved Messages) + selectedUser = SearchUser( + title = "Saved Messages", + username = "", + publicKey = accountPublicKey, + verified = 0, + online = 1 + ) }, onSettingsClick = { // TODO: Navigate to settings @@ -408,8 +468,8 @@ fun MainScreen( onNewChat = { // TODO: Show new chat screen }, - onUserSelect = { user -> - selectedUser = user + onUserSelect = { selectedChatUser -> + selectedUser = selectedChatUser }, onLogout = onLogout ) @@ -417,3 +477,4 @@ fun MainScreen( } } } + diff --git a/app/src/main/java/com/rosetta/messenger/network/Packets.kt b/app/src/main/java/com/rosetta/messenger/network/Packets.kt index 1dd0fa8..a7ead26 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Packets.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Packets.kt @@ -452,3 +452,33 @@ class PacketChunk : Packet() { return stream } } + +/** + * Push Token packet (ID: 0x0A) + * Отправка FCM/APNS токена на сервер для push-уведомлений + */ +class PacketPushToken : Packet() { + var privateKey: String = "" + var publicKey: String = "" + var pushToken: String = "" + var platform: String = "android" // "android" или "ios" + + override fun getPacketId(): Int = 0x0A + + override fun receive(stream: Stream) { + privateKey = stream.readString() + publicKey = stream.readString() + pushToken = stream.readString() + platform = stream.readString() + } + + override fun send(): Stream { + val stream = Stream() + stream.writeInt16(getPacketId()) + stream.writeString(privateKey) + stream.writeString(publicKey) + stream.writeString(pushToken) + stream.writeString(platform) + return stream + } +} diff --git a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt new file mode 100644 index 0000000..398cb61 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt @@ -0,0 +1,210 @@ +package com.rosetta.messenger.push + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.rosetta.messenger.MainActivity +import com.rosetta.messenger.R +import com.rosetta.messenger.crypto.CryptoManager +import com.rosetta.messenger.data.AccountManager +import com.rosetta.messenger.network.ProtocolManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * Firebase Cloud Messaging Service для обработки push-уведомлений + * + * Обрабатывает: + * - Получение нового FCM токена + * - Получение push-уведомлений о новых сообщениях + * - Отображение уведомлений + */ +class RosettaFirebaseMessagingService : FirebaseMessagingService() { + + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + companion object { + private const val TAG = "RosettaFCM" + private const val CHANNEL_ID = "rosetta_messages" + private const val CHANNEL_NAME = "Messages" + private const val NOTIFICATION_ID = 1 + } + + /** + * Вызывается когда получен новый FCM токен + * Отправляем его на сервер через протокол + */ + override fun onNewToken(token: String) { + super.onNewToken(token) + Log.d(TAG, "🔔 New FCM token (short): ${token.take(20)}...") + Log.d(TAG, "🔔 New FCM token (FULL): $token") + + // Сохраняем токен локально + saveFcmToken(token) + + // TODO: Отправляем токен на сервер если аккаунт уже залогинен + /* + serviceScope.launch { + try { + val accountManager = AccountManager(applicationContext) + val currentAccount = accountManager.getCurrentAccount() + + if (currentAccount != null) { + Log.d(TAG, "📤 Sending FCM token to server for account: ${currentAccount.publicKey.take(10)}...") + + // Отправляем через протокол + val packet = PacketPushToken().apply { + this.privateKey = CryptoManager.generatePrivateKeyHash(currentAccount.privateKey) + this.publicKey = currentAccount.publicKey + this.pushToken = token + this.platform = "android" + } + + ProtocolManager.send(packet) + Log.d(TAG, "✅ FCM token sent to server") + } + } catch (e: Exception) { + Log.e(TAG, "❌ Error sending FCM token to server", e) + } + } + */ + } + + /** + * Вызывается когда получено push-уведомление + */ + override fun onMessageReceived(remoteMessage: RemoteMessage) { + super.onMessageReceived(remoteMessage) + Log.d(TAG, "📬 Push notification received from: ${remoteMessage.from}") + + // Обрабатываем data payload + remoteMessage.data.isNotEmpty().let { + Log.d(TAG, "📦 Message data payload: ${remoteMessage.data}") + + val type = remoteMessage.data["type"] + val senderPublicKey = remoteMessage.data["sender_public_key"] + val senderName = remoteMessage.data["sender_name"] ?: senderPublicKey?.take(10) ?: "Unknown" + val messagePreview = remoteMessage.data["message_preview"] ?: "New message" + + when (type) { + "new_message" -> { + // Показываем уведомление о новом сообщении + showMessageNotification(senderPublicKey, senderName, messagePreview) + } + "message_read" -> { + // Сообщение прочитано - можно обновить UI если приложение открыто + Log.d(TAG, "📖 Message read by $senderPublicKey") + } + else -> { + Log.d(TAG, "⚠️ Unknown notification type: $type") + } + } + } + + // Обрабатываем notification payload (если есть) + remoteMessage.notification?.let { + Log.d(TAG, "📨 Message Notification Body: ${it.body}") + showSimpleNotification(it.title ?: "Rosetta", it.body ?: "New message") + } + } + + /** + * Показать уведомление о новом сообщении + */ + private fun showMessageNotification(senderPublicKey: String?, senderName: String, messagePreview: String) { + createNotificationChannel() + + // Intent для открытия чата + val intent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + putExtra("open_chat", senderPublicKey) + } + + val pendingIntent = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(senderName) + .setContentText(messagePreview) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .build() + + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(NOTIFICATION_ID, notification) + } + + /** + * Показать простое уведомление + */ + private fun showSimpleNotification(title: String, body: String) { + createNotificationChannel() + + val intent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + + val pendingIntent = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(title) + .setContentText(body) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .build() + + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(NOTIFICATION_ID, notification) + } + + /** + * Создать notification channel для Android 8+ + */ + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Notifications for new messages" + enableVibration(true) + } + + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + } + + /** + * Сохранить FCM токен в SharedPreferences + */ + private fun saveFcmToken(token: String) { + val prefs = getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE) + prefs.edit().putString("fcm_token", token).apply() + Log.d(TAG, "💾 FCM token saved locally") + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 771c045..86003a6 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -358,9 +358,6 @@ fun ChatDetailScreen( val chatTitle = if (isSavedMessages) "Saved Messages" else user.title.ifEmpty { user.publicKey.take(10) } - - // Состояние показа логов - var showLogs by remember { mutableStateOf(false) } // 📨 Forward: показывать ли выбор чата var showForwardPicker by remember { mutableStateOf(false) } @@ -375,12 +372,7 @@ fun ChatDetailScreen( chatsListViewModel.setAccount(currentUserPublicKey, currentUserPrivateKey) } } - // 🚀 Собираем логи ТОЛЬКО когда они показываются - иначе каждый лог вызывает перекомпозицию! - val debugLogs = if (showLogs) { - com.rosetta.messenger.network.ProtocolManager.debugLogs.collectAsState().value - } else { - emptyList() - } + // Состояние выпадающего меню var showMenu by remember { mutableStateOf(false) } @@ -935,39 +927,7 @@ fun ChatDetailScreen( else Color.Black.copy(alpha = 0.08f) ) } - - // Debug Logs - DropdownMenuItem( - text = { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 4.dp) - ) { - Icon( - Icons.Default.BugReport, - contentDescription = null, - tint = secondaryTextColor, - modifier = Modifier.size(22.dp) - ) - Spacer(modifier = Modifier.width(14.dp)) - Text( - "Debug Logs", - color = textColor, - fontSize = 16.sp, - fontWeight = FontWeight.Medium - ) - } - }, - onClick = { - showMenu = false - showLogs = true - }, - modifier = Modifier.padding(horizontal = 8.dp) - .background(inputBackgroundColor), - colors = MenuDefaults.itemColors( - textColor = textColor - ) - ) + } } } @@ -1390,58 +1350,6 @@ fun ChatDetailScreen( } } // Закрытие Box с fade-in - // Диалог логов - if (showLogs) { - AlertDialog( - onDismissRequest = { showLogs = false }, - containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White, - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - Text("Debug Logs (${debugLogs.size})", fontWeight = FontWeight.Bold, color = textColor) - IconButton( - onClick = { - com.rosetta.messenger.network.ProtocolManager.clearLogs() - } - ) { Icon(Icons.Default.Delete, contentDescription = "Clear", tint = Color(0xFFFF3B30)) } - } - }, - text = { - LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 500.dp)) { - items(debugLogs.reversed()) { log -> - val logColor = when { - log.contains("❌") || log.contains("Error") -> Color(0xFFFF3B30) - log.contains("✅") -> Color(0xFF38B24D) - log.contains("📤") -> PrimaryBlue - log.contains("📥") -> Color(0xFFFF9500) - log.contains("⚠️") -> Color(0xFFFFCC00) - else -> if (isDarkTheme) Color.White.copy(alpha = 0.8f) else Color.Black.copy(alpha = 0.8f) - } - Text( - text = log, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = logColor, - modifier = Modifier.padding(vertical = 2.dp) - ) - } - if (debugLogs.isEmpty()) { - item { - Text( - text = "No logs yet. Try sending a message.", - color = secondaryTextColor, - fontSize = 12.sp - ) - } - } - } - }, - confirmButton = { TextButton(onClick = { showLogs = false }) { Text("Close", color = PrimaryBlue) } } - ) - } // Диалог подтверждения удаления чата if (showDeleteConfirm) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 98d83ae..b0801b6 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -281,129 +281,290 @@ fun ChatsListScreen( drawerState = drawerState, drawerContent = { ModalDrawerSheet( - drawerContainerColor = drawerBackgroundColor, - modifier = Modifier.width(280.dp) + drawerContainerColor = Color.Transparent, + windowInsets = WindowInsets(0), // 🎨 Убираем системные отступы - drawer идет до верха + modifier = Modifier.width(300.dp) ) { - // Drawer Header Column( - modifier = - Modifier.fillMaxWidth() - .padding( - top = 48.dp, - start = 16.dp, - end = 16.dp, - bottom = 16.dp - ) + modifier = Modifier + .fillMaxSize() + .background(drawerBackgroundColor) ) { - // Avatar - val avatarColors = getAvatarColor(accountPublicKey, isDarkTheme) + // ═══════════════════════════════════════════════════════════ + // 🎨 DRAWER HEADER - Avatar and status + // ═══════════════════════════════════════════════════════════ + val headerColor = if (isDarkTheme) { + Color(0xFF2C5282) + } else { + Color(0xFF4A90D9) + } + Box( - modifier = - Modifier.size(64.dp) - .clip(CircleShape) - .background(avatarColors.backgroundColor), - contentAlignment = Alignment.Center + modifier = Modifier + .fillMaxWidth() + .background(color = headerColor) + .statusBarsPadding() // 🎨 Контент начинается после status bar + .padding( + top = 16.dp, + start = 20.dp, + end = 20.dp, + bottom = 20.dp + ) ) { - Text( - text = getAvatarText(accountPublicKey), - fontSize = 24.sp, - fontWeight = FontWeight.Bold, - color = avatarColors.textColor + Column { + // Avatar with border + val avatarColors = getAvatarColor(accountPublicKey, isDarkTheme) + Box( + modifier = Modifier + .size(72.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.2f)) + .padding(3.dp) + .clip(CircleShape) + .background(avatarColors.backgroundColor), + contentAlignment = Alignment.Center + ) { + Text( + text = getAvatarText(accountPublicKey), + fontSize = 26.sp, + fontWeight = FontWeight.Bold, + color = avatarColors.textColor + ) + } + + Spacer(modifier = Modifier.height(14.dp)) + + // Public key (username style) - clickable для копирования + val truncatedKey = + if (accountPublicKey.length > 16) { + "${accountPublicKey.take(8)}...${accountPublicKey.takeLast(6)}" + } else accountPublicKey + + val context = androidx.compose.ui.platform.LocalContext.current + var showCopiedToast by remember { mutableStateOf(false) } + + // Плавная замена текста + AnimatedContent( + targetState = showCopiedToast, + transitionSpec = { + fadeIn(animationSpec = tween(300)) togetherWith + fadeOut(animationSpec = tween(300)) + }, + label = "copiedAnimation" + ) { isCopied -> + Text( + text = if (isCopied) "Copied!" else truncatedKey, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White, + fontStyle = if (isCopied) androidx.compose.ui.text.font.FontStyle.Italic else androidx.compose.ui.text.font.FontStyle.Normal, + modifier = Modifier.clickable { + if (!showCopiedToast) { + // Копируем публичный ключ + val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + val clip = android.content.ClipData.newPlainText("Public Key", accountPublicKey) + clipboard.setPrimaryClip(clip) + showCopiedToast = true + } + } + ) + } + + // Автоматически возвращаем обратно через 1.5 секунды + if (showCopiedToast) { + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(1500) + showCopiedToast = false + } + } + + Spacer(modifier = Modifier.height(6.dp)) + + // Connection status indicator + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { showStatusDialog = true } + ) { + val statusColor = when (protocolState) { + ProtocolState.AUTHENTICATED -> Color(0xFF4ADE80) + ProtocolState.CONNECTING, ProtocolState.CONNECTED, ProtocolState.HANDSHAKING -> Color(0xFFFBBF24) + else -> Color(0xFFF87171) + } + val statusText = when (protocolState) { + ProtocolState.AUTHENTICATED -> "Online" + ProtocolState.CONNECTING, ProtocolState.CONNECTED, ProtocolState.HANDSHAKING -> "Connecting..." + else -> "Offline" + } + + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(statusColor) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = statusText, + fontSize = 13.sp, + color = Color.White.copy(alpha = 0.85f) + ) + } + } + } + + // ═══════════════════════════════════════════════════════════ + // 📱 MENU ITEMS + // ═══════════════════════════════════════════════════════════ + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp) + ) { + val menuIconColor = if (isDarkTheme) Color(0xFFB0B0B0) else Color(0xFF5C5C5C) + + // 👤 Profile Section + DrawerMenuItemEnhanced( + icon = Icons.Outlined.Person, + text = "My Profile", + iconColor = menuIconColor, + textColor = textColor, + onClick = { + scope.launch { drawerState.close() } + onProfileClick() + } + ) + + // 📖 Saved Messages + DrawerMenuItemEnhanced( + icon = Icons.Outlined.Bookmark, + text = "Saved Messages", + iconColor = menuIconColor, + textColor = textColor, + onClick = { + scope.launch { drawerState.close() } + onSavedMessagesClick() + } + ) + + DrawerDivider(isDarkTheme) + + // 👥 Contacts + DrawerMenuItemEnhanced( + icon = Icons.Outlined.Contacts, + text = "Contacts", + iconColor = menuIconColor, + textColor = textColor, + onClick = { + scope.launch { drawerState.close() } + onContactsClick() + } + ) + + // 📞 Calls + DrawerMenuItemEnhanced( + icon = Icons.Outlined.Call, + text = "Calls", + iconColor = menuIconColor, + textColor = textColor, + onClick = { + scope.launch { drawerState.close() } + onCallsClick() + } + ) + + // ➕ Invite Friends + DrawerMenuItemEnhanced( + icon = Icons.Outlined.PersonAdd, + text = "Invite Friends", + iconColor = menuIconColor, + textColor = textColor, + onClick = { + scope.launch { drawerState.close() } + onInviteFriendsClick() + } + ) + + DrawerDivider(isDarkTheme) + + // ⚙️ Settings + DrawerMenuItemEnhanced( + icon = Icons.Outlined.Settings, + text = "Settings", + iconColor = menuIconColor, + textColor = textColor, + onClick = { + scope.launch { drawerState.close() } + onSettingsClick() + } + ) + + // 🌓 Theme Toggle + DrawerMenuItemEnhanced( + icon = if (isDarkTheme) Icons.Outlined.LightMode else Icons.Outlined.DarkMode, + text = if (isDarkTheme) "Light Mode" else "Dark Mode", + iconColor = menuIconColor, + textColor = textColor, + onClick = { onToggleTheme() } + ) + + // ❓ Help + DrawerMenuItemEnhanced( + icon = Icons.Outlined.HelpOutline, + text = "Help & FAQ", + iconColor = menuIconColor, + textColor = textColor, + onClick = { + scope.launch { drawerState.close() } + // TODO: Add help screen navigation + } ) } - Spacer(modifier = Modifier.height(16.dp)) - - // Public key - val truncatedKey = - if (accountPublicKey.length > 12) { - "${accountPublicKey.take(6)}...${accountPublicKey.takeLast(4)}" - } else accountPublicKey - - Text( - text = truncatedKey, - fontSize = 17.sp, - fontWeight = FontWeight.SemiBold, - color = textColor - ) - } - - Divider(color = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)) - - // Menu Items - прозрачный фон без странных цветов - val drawerItemColors = NavigationDrawerItemDefaults.colors( - unselectedContainerColor = Color.Transparent, - unselectedIconColor = if (isDarkTheme) Color.White else Color.Black, - unselectedTextColor = textColor - ) - - NavigationDrawerItem( - icon = { Icon(Icons.Outlined.Person, contentDescription = null) }, - label = { Text("My Profile") }, - selected = false, - colors = drawerItemColors, - onClick = { - scope.launch { drawerState.close() } - onProfileClick() - } - ) - - NavigationDrawerItem( - icon = { Icon(Icons.Outlined.Settings, contentDescription = null) }, - label = { Text("Settings") }, - selected = false, - colors = drawerItemColors, - onClick = { - scope.launch { drawerState.close() } - onSettingsClick() - } - ) - - NavigationDrawerItem( - icon = { - Icon( - if (isDarkTheme) Icons.Default.LightMode - else Icons.Default.DarkMode, - contentDescription = null - ) - }, - label = { Text(if (isDarkTheme) "Light Mode" else "Dark Mode") }, - selected = false, - colors = drawerItemColors, - onClick = { - // Don't close drawer when toggling theme - onToggleTheme() - } - ) - - Spacer(modifier = Modifier.weight(1f)) - - // Logout - Divider(color = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)) - - NavigationDrawerItem( - icon = { - Icon( - Icons.Default.Logout, - contentDescription = null, - tint = Color(0xFFFF3B30) - ) - }, - label = { Text("Log Out", color = Color(0xFFFF3B30)) }, - selected = false, - colors = NavigationDrawerItemDefaults.colors( - unselectedContainerColor = Color.Transparent - ), - onClick = { - scope.launch { - drawerState.close() - kotlinx.coroutines.delay(150) - onLogout() + // ═══════════════════════════════════════════════════════════ + // 🚪 FOOTER - Logout & Version + // ═══════════════════════════════════════════════════════════ + Column( + modifier = Modifier.fillMaxWidth() + ) { + Divider( + color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8), + thickness = 0.5.dp + ) + + // Logout + DrawerMenuItemEnhanced( + icon = Icons.Outlined.Logout, + text = "Log Out", + iconColor = Color(0xFFFF4444), + textColor = Color(0xFFFF4444), + onClick = { + scope.launch { + drawerState.close() + kotlinx.coroutines.delay(150) + onLogout() + } } - } - ) + ) - Spacer(modifier = Modifier.height(24.dp)) + // Version info + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp), + contentAlignment = Alignment.CenterStart + ) { + Text( + text = "Rosetta v1.0.0", + fontSize = 12.sp, + color = if (isDarkTheme) Color(0xFF666666) else Color(0xFF999999) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } } } ) { @@ -1442,7 +1603,7 @@ fun RequestsScreen( modifier = Modifier.fillMaxSize() ) { items(requests, key = { it.opponentKey }) { request -> - DialogItem( + DialogItemContent( dialog = request, isDarkTheme = isDarkTheme, isTyping = false, @@ -1459,3 +1620,73 @@ fun RequestsScreen( } } } + +/** + * 🎨 Enhanced Drawer Menu Item - красивый пункт меню с hover эффектом + */ +@Composable +fun DrawerMenuItemEnhanced( + icon: androidx.compose.ui.graphics.vector.ImageVector, + text: String, + iconColor: Color, + textColor: Color, + badge: String? = null, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(24.dp) + ) + + Spacer(modifier = Modifier.width(20.dp)) + + Text( + text = text, + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = textColor, + modifier = Modifier.weight(1f) + ) + + badge?.let { + Box( + modifier = Modifier + .background( + color = Color(0xFF4A90D9), + shape = RoundedCornerShape(10.dp) + ) + .padding(horizontal = 8.dp, vertical = 2.dp) + ) { + Text( + text = it, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = Color.White + ) + } + } + } +} + +/** + * 📏 Drawer Divider - разделитель между секциями + */ +@Composable +fun DrawerDivider(isDarkTheme: Boolean) { + Spacer(modifier = Modifier.height(8.dp)) + Divider( + modifier = Modifier.padding(horizontal = 20.dp), + color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFEEEEEE), + thickness = 0.5.dp + ) + Spacer(modifier = Modifier.height(8.dp)) +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt index 1dce4ae..1a7696d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt @@ -253,8 +253,30 @@ fun SearchScreen( } } else { // Search Results + // Проверяем, не ищет ли пользователь сам себя (Saved Messages) + val isSavedMessagesSearch = searchQuery.trim().let { query -> + query.equals(currentUserPublicKey, ignoreCase = true) || + query.equals(currentUserPublicKey.take(8), ignoreCase = true) || + query.equals(currentUserPublicKey.takeLast(8), ignoreCase = true) + } + + // Если ищем себя - показываем Saved Messages как первый результат + val resultsWithSavedMessages = if (isSavedMessagesSearch && searchResults.none { it.publicKey == currentUserPublicKey }) { + listOf( + SearchUser( + title = "Saved Messages", + username = "", + publicKey = currentUserPublicKey, + verified = 0, + online = 1 + ) + ) + searchResults.filter { it.publicKey != currentUserPublicKey } + } else { + searchResults + } + SearchResultsList( - searchResults = searchResults, + searchResults = resultsWithSavedMessages, isSearching = isSearching, currentUserPublicKey = currentUserPublicKey, isDarkTheme = isDarkTheme, @@ -262,8 +284,10 @@ fun SearchScreen( onUserClick = { user -> // Мгновенно закрываем клавиатуру hideKeyboardInstantly() - // Сохраняем пользователя в историю - RecentSearchesManager.addUser(user) + // Сохраняем пользователя в историю (кроме Saved Messages) + if (user.publicKey != currentUserPublicKey) { + RecentSearchesManager.addUser(user) + } onUserSelect(user) } ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/theme/Type.kt b/app/src/main/java/com/rosetta/messenger/ui/theme/Type.kt index cc452ec..4e887c4 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/theme/Type.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/theme/Type.kt @@ -6,33 +6,73 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp +/** + * Telegram-inspired typography system + * Based on Telegram Android typography with system default font (Roboto) + */ val Typography = Typography( + // Основной текст сообщений (как в Telegram: 16sp) bodyLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ), - titleLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Bold, - fontSize = 28.sp, - lineHeight = 34.sp, + lineHeight = 22.4.sp, // 1.4x letterSpacing = 0.sp ), + // Имя/заголовок в диалогах (17sp как в Telegram) + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 17.sp, + lineHeight = 23.8.sp, // 1.4x + letterSpacing = 0.sp + ), + // Заголовок большой (20sp) + headlineLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + // Основной текст UI (16sp) bodyMedium = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.25.sp + fontSize = 16.sp, + lineHeight = 22.4.sp, // 1.4x + letterSpacing = 0.sp ), + // Мелкий текст: время, статусы (13sp) + bodySmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 13.sp, + lineHeight = 18.2.sp, // 1.4x + letterSpacing = 0.sp + ), + // Лейбл для кнопок (14sp) labelLarge = TextStyle( fontFamily = FontFamily.Default, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - lineHeight = 24.sp, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.sp + ), + // Средний лейбл (14sp) + labelMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 19.6.sp, // 1.4x + letterSpacing = 0.sp + ), + // Мелкий лейбл (13sp) + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 13.sp, + lineHeight = 18.2.sp, // 1.4x letterSpacing = 0.sp ) ) diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..a851cb7 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,9 @@ + + + + #3390EC + + + #2B7CD3 + #9BCDFF + diff --git a/build.gradle.kts b/build.gradle.kts index 4c3bff7..8026e37 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,6 +3,7 @@ plugins { id("com.android.application") version "8.2.0" apply false id("org.jetbrains.kotlin.android") version "1.9.20" apply false id("com.google.devtools.ksp") version "1.9.20-1.0.14" apply false + id("com.google.gms.google-services") version "4.4.0" apply false } tasks.register("clean", Delete::class) {