feat: Integrate Firebase Cloud Messaging for push notifications; add service to handle token and message reception

This commit is contained in:
k1ngsterr1
2026-01-16 23:06:41 +05:00
parent 7750f450e8
commit 431e3755c6
14 changed files with 1317 additions and 234 deletions

306
FCM_SETUP.md Normal file
View File

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

207
FCM_TODO.md Normal file
View File

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

View File

@@ -2,6 +2,7 @@ plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
id("kotlin-kapt") id("kotlin-kapt")
id("com.google.gms.google-services")
} }
android { android {
@@ -110,6 +111,11 @@ dependencies {
// Baseline Profiles for startup performance // Baseline Profiles for startup performance
implementation("androidx.profileinstaller:profileinstaller:1.3.1") 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 // Testing dependencies
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.8") testImplementation("io.mockk:mockk:1.13.8")

View File

@@ -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"
}

View File

@@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -16,6 +17,7 @@
android:theme="@style/Theme.RosettaAndroid" android:theme="@style/Theme.RosettaAndroid"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="31"> tools:targetApi="31">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -28,6 +30,25 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Firebase Cloud Messaging Service -->
<service
android:name=".push.RosettaFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- Firebase notification icon (optional, for better looking notifications) -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_launcher_foreground" />
<!-- Firebase notification color (optional) -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/primary_blue" />
</application> </application>
</manifest> </manifest>

View File

@@ -1,6 +1,7 @@
package com.rosetta.messenger package com.rosetta.messenger
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge 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.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.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.AccountManager
import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.network.PacketPushToken
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.ui.auth.AccountInfo import com.rosetta.messenger.ui.auth.AccountInfo
import com.rosetta.messenger.ui.auth.AuthFlow import com.rosetta.messenger.ui.auth.AuthFlow
@@ -49,6 +54,10 @@ class MainActivity : ComponentActivity() {
private lateinit var preferencesManager: PreferencesManager private lateinit var preferencesManager: PreferencesManager
private lateinit var accountManager: AccountManager private lateinit var accountManager: AccountManager
companion object {
private const val TAG = "MainActivity"
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
@@ -60,6 +69,9 @@ class MainActivity : ComponentActivity() {
// 🔥 Инициализируем ProtocolManager для обработки онлайн статусов // 🔥 Инициализируем ProtocolManager для обработки онлайн статусов
ProtocolManager.initialize(this) ProtocolManager.initialize(this)
// 🔔 Инициализируем Firebase для push-уведомлений
initializeFirebase()
// 🚀 Предзагружаем эмодзи в фоне для мгновенного открытия пикера // 🚀 Предзагружаем эмодзи в фоне для мгновенного открытия пикера
// Используем новый оптимизированный кэш // Используем новый оптимизированный кэш
OptimizedEmojiCache.preload(this) OptimizedEmojiCache.preload(this)
@@ -153,7 +165,7 @@ class MainActivity : ComponentActivity() {
} }
) )
} }
"auth_new", "auth_new", "auth_unlock" -> { "auth_new", "auth_unlock" -> {
AuthFlow( AuthFlow(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
hasExistingAccount = screen == "auth_unlock", 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 @Composable
@@ -333,12 +386,12 @@ fun MainScreen(
} }
}, },
label = "screenNavigation" label = "screenNavigation"
) { (user, isSearchOpen, _) -> ) { (currentUser, isSearchOpen, _) ->
when { when {
user != null -> { currentUser != null -> {
// Экран чата // Экран чата
ChatDetailScreen( ChatDetailScreen(
user = user, user = currentUser,
currentUserPublicKey = accountPublicKey, currentUserPublicKey = accountPublicKey,
currentUserPrivateKey = accountPrivateKey, currentUserPrivateKey = accountPrivateKey,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
@@ -365,9 +418,9 @@ fun MainScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
protocolState = protocolState, protocolState = protocolState,
onBackClick = { showSearchScreen = false }, onBackClick = { showSearchScreen = false },
onUserSelect = { user -> onUserSelect = { selectedSearchUser ->
showSearchScreen = false showSearchScreen = false
selectedUser = user selectedUser = selectedSearchUser
} }
) )
} }
@@ -394,7 +447,14 @@ fun MainScreen(
// TODO: Navigate to calls // TODO: Navigate to calls
}, },
onSavedMessagesClick = { onSavedMessagesClick = {
// TODO: Navigate to saved messages // Открываем чат с самим собой (Saved Messages)
selectedUser = SearchUser(
title = "Saved Messages",
username = "",
publicKey = accountPublicKey,
verified = 0,
online = 1
)
}, },
onSettingsClick = { onSettingsClick = {
// TODO: Navigate to settings // TODO: Navigate to settings
@@ -408,8 +468,8 @@ fun MainScreen(
onNewChat = { onNewChat = {
// TODO: Show new chat screen // TODO: Show new chat screen
}, },
onUserSelect = { user -> onUserSelect = { selectedChatUser ->
selectedUser = user selectedUser = selectedChatUser
}, },
onLogout = onLogout onLogout = onLogout
) )
@@ -417,3 +477,4 @@ fun MainScreen(
} }
} }
} }

View File

@@ -452,3 +452,33 @@ class PacketChunk : Packet() {
return stream 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
}
}

View File

@@ -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")
}
}

View File

@@ -359,9 +359,6 @@ fun ChatDetailScreen(
if (isSavedMessages) "Saved Messages" if (isSavedMessages) "Saved Messages"
else user.title.ifEmpty { user.publicKey.take(10) } else user.title.ifEmpty { user.publicKey.take(10) }
// Состояние показа логов
var showLogs by remember { mutableStateOf(false) }
// 📨 Forward: показывать ли выбор чата // 📨 Forward: показывать ли выбор чата
var showForwardPicker by remember { mutableStateOf(false) } var showForwardPicker by remember { mutableStateOf(false) }
@@ -375,12 +372,7 @@ fun ChatDetailScreen(
chatsListViewModel.setAccount(currentUserPublicKey, currentUserPrivateKey) chatsListViewModel.setAccount(currentUserPublicKey, currentUserPrivateKey)
} }
} }
// 🚀 Собираем логи ТОЛЬКО когда они показываются - иначе каждый лог вызывает перекомпозицию!
val debugLogs = if (showLogs) {
com.rosetta.messenger.network.ProtocolManager.debugLogs.collectAsState().value
} else {
emptyList()
}
// Состояние выпадающего меню // Состояние выпадающего меню
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
@@ -936,38 +928,6 @@ fun ChatDetailScreen(
) )
} }
// 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 } // Закрытие 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) { if (showDeleteConfirm) {

View File

@@ -281,129 +281,290 @@ fun ChatsListScreen(
drawerState = drawerState, drawerState = drawerState,
drawerContent = { drawerContent = {
ModalDrawerSheet( ModalDrawerSheet(
drawerContainerColor = drawerBackgroundColor, drawerContainerColor = Color.Transparent,
modifier = Modifier.width(280.dp) windowInsets = WindowInsets(0), // 🎨 Убираем системные отступы - drawer идет до верха
modifier = Modifier.width(300.dp)
) { ) {
// Drawer Header
Column( Column(
modifier = modifier = Modifier
Modifier.fillMaxWidth() .fillMaxSize()
.padding( .background(drawerBackgroundColor)
top = 48.dp,
start = 16.dp,
end = 16.dp,
bottom = 16.dp
)
) { ) {
// Avatar // ═══════════════════════════════════════════════════════════
val avatarColors = getAvatarColor(accountPublicKey, isDarkTheme) // 🎨 DRAWER HEADER - Avatar and status
// ═══════════════════════════════════════════════════════════
val headerColor = if (isDarkTheme) {
Color(0xFF2C5282)
} else {
Color(0xFF4A90D9)
}
Box( Box(
modifier = modifier = Modifier
Modifier.size(64.dp) .fillMaxWidth()
.clip(CircleShape) .background(color = headerColor)
.background(avatarColors.backgroundColor), .statusBarsPadding() // 🎨 Контент начинается после status bar
contentAlignment = Alignment.Center .padding(
top = 16.dp,
start = 20.dp,
end = 20.dp,
bottom = 20.dp
)
) { ) {
Text( Column {
text = getAvatarText(accountPublicKey), // Avatar with border
fontSize = 24.sp, val avatarColors = getAvatarColor(accountPublicKey, isDarkTheme)
fontWeight = FontWeight.Bold, Box(
color = avatarColors.textColor 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)) // ═══════════════════════════════════════════════════════════
// 🚪 FOOTER - Logout & Version
// ═══════════════════════════════════════════════════════════
Column(
modifier = Modifier.fillMaxWidth()
) {
Divider(
color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8),
thickness = 0.5.dp
)
// Public key // Logout
val truncatedKey = DrawerMenuItemEnhanced(
if (accountPublicKey.length > 12) { icon = Icons.Outlined.Logout,
"${accountPublicKey.take(6)}...${accountPublicKey.takeLast(4)}" text = "Log Out",
} else accountPublicKey iconColor = Color(0xFFFF4444),
textColor = Color(0xFFFF4444),
Text( onClick = {
text = truncatedKey, scope.launch {
fontSize = 17.sp, drawerState.close()
fontWeight = FontWeight.SemiBold, kotlinx.coroutines.delay(150)
color = textColor onLogout()
) }
}
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()
} }
} )
)
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() modifier = Modifier.fillMaxSize()
) { ) {
items(requests, key = { it.opponentKey }) { request -> items(requests, key = { it.opponentKey }) { request ->
DialogItem( DialogItemContent(
dialog = request, dialog = request,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
isTyping = false, 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))
}

View File

@@ -253,8 +253,30 @@ fun SearchScreen(
} }
} else { } else {
// Search Results // 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( SearchResultsList(
searchResults = searchResults, searchResults = resultsWithSavedMessages,
isSearching = isSearching, isSearching = isSearching,
currentUserPublicKey = currentUserPublicKey, currentUserPublicKey = currentUserPublicKey,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
@@ -262,8 +284,10 @@ fun SearchScreen(
onUserClick = { user -> onUserClick = { user ->
// Мгновенно закрываем клавиатуру // Мгновенно закрываем клавиатуру
hideKeyboardInstantly() hideKeyboardInstantly()
// Сохраняем пользователя в историю // Сохраняем пользователя в историю (кроме Saved Messages)
RecentSearchesManager.addUser(user) if (user.publicKey != currentUserPublicKey) {
RecentSearchesManager.addUser(user)
}
onUserSelect(user) onUserSelect(user)
} }
) )

View File

@@ -6,33 +6,73 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
/**
* Telegram-inspired typography system
* Based on Telegram Android typography with system default font (Roboto)
*/
val Typography = Typography( val Typography = Typography(
// Основной текст сообщений (как в Telegram: 16sp)
bodyLarge = TextStyle( bodyLarge = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 24.sp, lineHeight = 22.4.sp, // 1.4x
letterSpacing = 0.5.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 34.sp,
letterSpacing = 0.sp 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( bodyMedium = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 14.sp, fontSize = 16.sp,
lineHeight = 20.sp, lineHeight = 22.4.sp, // 1.4x
letterSpacing = 0.25.sp 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( labelLarge = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.Medium,
fontSize = 16.sp, fontSize = 14.sp,
lineHeight = 24.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 letterSpacing = 0.sp
) )
) )

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Primary brand color - синий из Telegram -->
<color name="primary_blue">#3390EC</color>
<!-- Additional colors if needed -->
<color name="primary_blue_pressed">#2B7CD3</color>
<color name="primary_blue_disabled">#9BCDFF</color>
</resources>

View File

@@ -3,6 +3,7 @@ plugins {
id("com.android.application") version "8.2.0" apply false id("com.android.application") version "8.2.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.20" 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.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) { tasks.register("clean", Delete::class) {