feat: Integrate Firebase Cloud Messaging for push notifications; add service to handle token and message reception
This commit is contained in:
306
FCM_SETUP.md
Normal file
306
FCM_SETUP.md
Normal 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
207
FCM_TODO.md
Normal 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)
|
||||||
@@ -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")
|
||||||
|
|||||||
29
app/google-services.json.example
Normal file
29
app/google-services.json.example
Normal 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"
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -358,9 +358,6 @@ fun ChatDetailScreen(
|
|||||||
val chatTitle =
|
val chatTitle =
|
||||||
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) }
|
||||||
@@ -935,39 +927,7 @@ fun ChatDetailScreen(
|
|||||||
else Color.Black.copy(alpha = 0.08f)
|
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
|
} // Закрытие 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) {
|
||||||
|
|||||||
@@ -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
|
||||||
// Public key
|
// ═══════════════════════════════════════════════════════════
|
||||||
val truncatedKey =
|
Column(
|
||||||
if (accountPublicKey.length > 12) {
|
modifier = Modifier.fillMaxWidth()
|
||||||
"${accountPublicKey.take(6)}...${accountPublicKey.takeLast(4)}"
|
) {
|
||||||
} else accountPublicKey
|
Divider(
|
||||||
|
color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8),
|
||||||
Text(
|
thickness = 0.5.dp
|
||||||
text = truncatedKey,
|
)
|
||||||
fontSize = 17.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
// Logout
|
||||||
color = textColor
|
DrawerMenuItemEnhanced(
|
||||||
)
|
icon = Icons.Outlined.Logout,
|
||||||
}
|
text = "Log Out",
|
||||||
|
iconColor = Color(0xFFFF4444),
|
||||||
Divider(color = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8))
|
textColor = Color(0xFFFF4444),
|
||||||
|
onClick = {
|
||||||
// Menu Items - прозрачный фон без странных цветов
|
scope.launch {
|
||||||
val drawerItemColors = NavigationDrawerItemDefaults.colors(
|
drawerState.close()
|
||||||
unselectedContainerColor = Color.Transparent,
|
kotlinx.coroutines.delay(150)
|
||||||
unselectedIconColor = if (isDarkTheme) Color.White else Color.Black,
|
onLogout()
|
||||||
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
9
app/src/main/res/values/colors.xml
Normal file
9
app/src/main/res/values/colors.xml
Normal 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>
|
||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user