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) {