Добавлены Пуши

This commit is contained in:
Artur 2026-04-26 00:07:35 +05:00
parent 40d715539e
commit 1b8670d811
21 changed files with 1004 additions and 37 deletions

5
.gitignore vendored
View File

@ -14,6 +14,11 @@ migrate_working_dir/
*.db
.env
venv/
.venv/
chepuhagram-6ca5d-firebase-adminsdk-fbsvc-cf8a5ad2f3.json
.firebaserc
firebase-tools-instant-win.exe
google-services.json
# IntelliJ related
*.iml

View File

@ -3,14 +3,16 @@ plugins {
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
id("com.google.gms.google-services")
}
android {
namespace = "com.example.chepuhagram"
compileSdk = flutter.compileSdkVersion
namespace = "ru.chepuhagram.app"
compileSdk = 36
ndkVersion = flutter.ndkVersion
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
@ -21,11 +23,11 @@ android {
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.chepuhagram"
applicationId = "ru.chepuhagram.app"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
targetSdk = 36
versionCode = flutter.versionCode
versionName = flutter.versionName
}
@ -42,3 +44,9 @@ android {
flutter {
source = "../.."
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
implementation(platform("com.google.firebase:firebase-bom:34.12.0"))
implementation("com.google.firebase:firebase-messaging")
}

View File

@ -1,4 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:label="Chepuhagram"
android:name="${applicationName}"
@ -25,6 +28,16 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Firebase Messaging Service -->
<service
android:name="com.google.firebase.messaging.FirebaseMessagingService"
android:exported="false">
<intent-filter android:priority="-500">
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

View File

@ -1,4 +1,4 @@
package com.example.chepuhagram
package ru.chepuhagram.app
import io.flutter.embedding.android.FlutterActivity

View File

@ -22,3 +22,7 @@ subprojects {
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
plugins {
id("com.google.gms.google-services") version "4.4.4" apply false
}

View File

@ -64,6 +64,32 @@ class ApiService extends ChangeNotifier {
return token;
}
Future<bool> updateFcmToken(String fcmtoken) async {
notifyListeners();
try {
final token = await getAccessToken();
final response = await _client.post(
Uri.http(AppConstants.baseUrl, 'auth/update-fcm', {'token': fcmtoken}),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
);
if (response.statusCode == 200) {
return true;
} else {
print("Ошибка установки ключа: ${response.statusCode}");
return false;
}
} catch (e) {
rethrow;
} finally {
notifyListeners();
}
}
Future<bool> setPublicKey(String publicKey) async {
notifyListeners();

View File

@ -1,13 +1,174 @@
import 'package:chepuhagram/presentation/screens/splash_screen.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'data/datasources/ws_client.dart';
import 'logic/auth_provider.dart';
import 'logic/contact_provider.dart';
import 'core/theme_manager.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:chepuhagram/domain/services/crypto_service.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'dart:convert';
import 'package:chepuhagram/presentation/screens/chat_screen.dart';
import 'package:chepuhagram/presentation/screens/contacts_screen.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'presentation/screens/splash_screen.dart';
void main() {
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
// Глобальная переменная для отслеживания текущего активного контакта в чате
int? currentActiveChatContactId;
// Глобальная переменная для хранения начального сообщения (при запуске из уведомления)
RemoteMessage? initialMessage;
// Ключ для SharedPreferences
const String _notificationLaunchKey = 'notification_launch_data';
Future<void> _onSelectNotification(NotificationResponse notificationResponse) async {
final payload = notificationResponse.payload;
if (payload != null) {
try {
final data = jsonDecode(payload);
final senderId = int.tryParse(data['sender_id']?.toString() ?? '');
if (senderId != null) {
print('Notification selected, payload sender_id=$senderId');
final context = navigatorKey.currentContext;
final prefs = await SharedPreferences.getInstance();
// Важно: не сохраняем payload в SharedPreferences, если можем сразу перейти в чат.
// Иначе при следующем обычном запуске (по иконке) останется "хвост" и приложение
// будет снова автопереходить в чат.
if (context == null) {
await prefs.setString(_notificationLaunchKey, jsonEncode(data));
print('Navigator context is null, saved notification payload to SharedPreferences');
} else {
await prefs.remove(_notificationLaunchKey);
}
// Navigate to chat with this contact (if context is ready)
_navigateToChat(senderId);
} else {
print('Notification payload has invalid sender_id: ${data['sender_id']}');
}
} catch (e) {
print('Error parsing notification payload: $e');
}
}
}
void _navigateToChat(int senderId) {
print('Navigating to chat with senderId: $senderId');
final context = navigatorKey.currentContext;
if (context != null) {
final contactProvider = Provider.of<ContactProvider>(context, listen: false);
// Check if contacts are loaded
if (contactProvider.contacts.isEmpty) {
print('Contacts not loaded yet, navigating to contacts screen first');
// Navigate to contacts screen and pass the senderId to navigate to chat later
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ContactsScreen(targetChatId: senderId),
),
);
return;
}
try {
final contact = contactProvider.contacts.firstWhere(
(c) => c.id == senderId,
orElse: () => throw Exception('Contact not found'),
);
print('Found contact: ${contact.username}, navigating to chat');
currentActiveChatContactId = senderId; // Устанавливаем активный чат
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ChatScreen(contact: contact),
),
);
} catch (e) {
print('Contact with id $senderId not found, navigating to contacts screen');
// Contact not found, go to contacts screen
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const ContactsScreen(),
),
);
}
} else {
print('Navigator context is null');
}
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
// Проверяем, было ли приложение запущено из уведомления
// Добавляем небольшую задержку, чтобы Firebase полностью инициализировался
await Future.delayed(const Duration(milliseconds: 500));
initialMessage = await FirebaseMessaging.instance.getInitialMessage();
print('Initial message from main() after delay: $initialMessage');
// Сохраняем информацию в SharedPreferences для надежности
final prefs = await SharedPreferences.getInstance();
if (initialMessage != null) {
print('App launched from notification: ${initialMessage!.data}');
print('Message type: ${initialMessage!.data['type']}');
print('Sender ID: ${initialMessage!.data['sender_id']}');
// Сохраняем данные уведомления
await prefs.setString(_notificationLaunchKey, jsonEncode(initialMessage!.data));
print('Saved notification data to SharedPreferences');
} else {
print('No initial message - app launched normally');
// Очищаем сохраненные данные, если приложение запущено нормально
await prefs.remove(_notificationLaunchKey);
}
// Initialize local notifications
const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('@mipmap/ic_launcher');
final InitializationSettings initializationSettings = InitializationSettings(android: initializationSettingsAndroid);
await flutterLocalNotificationsPlugin.initialize(
initializationSettings,
onDidReceiveNotificationResponse: _onSelectNotification,
);
// Если приложение было запущено из локального уведомления, сохраним payload
final notificationAppLaunchDetails = await flutterLocalNotificationsPlugin.getNotificationAppLaunchDetails();
if (notificationAppLaunchDetails?.didNotificationLaunchApp ?? false) {
final payload = notificationAppLaunchDetails?.notificationResponse?.payload;
print('App launched from local notification, payload: $payload');
if (payload != null && payload.isNotEmpty) {
try {
final data = jsonDecode(payload);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_notificationLaunchKey, jsonEncode(data));
print('Saved local notification launch payload to SharedPreferences');
} catch (e) {
print('Failed to save notification launch payload: $e');
}
}
}
// Create notification channel for Android 8+
const AndroidNotificationChannel channel = AndroidNotificationChannel(
'chat_id', // id
'Messages', // title
description: 'Chat messages notifications', // description
importance: Importance.high,
);
await flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()?.createNotificationChannel(channel);
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
runApp(
MultiProvider(
@ -22,6 +183,69 @@ void main() {
);
}
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
print("Фоновый пуш получен: ${message.data}");
if (message.data['type'] == 'enc_message') {
try {
// Initialize notifications for background
const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('@mipmap/ic_launcher');
const InitializationSettings initializationSettings = InitializationSettings(android: initializationSettingsAndroid);
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
// Create notification channel
const AndroidNotificationChannel channel = AndroidNotificationChannel(
'chat_id',
'Messages',
description: 'Chat messages notifications',
importance: Importance.high,
);
await flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()?.createNotificationChannel(channel);
// Try to decrypt
String notificationText = 'New encrypted message';
try {
// 1. Инициализируем крипто-сервис
final crypto = CryptoService();
// 2. Достаем ключи (они должны быть в SecureStorage)
final myPrivKey = await crypto.getPrivateKey();
print('Private key retrieved: ${myPrivKey != null}');
if (myPrivKey == null) {
print('Private key not found, showing encrypted message');
notificationText = 'Encrypted message: ${message.data['content']?.substring(0, 50) ?? 'N/A'}...';
} else {
// 3. Расшифровываем
final sharedSecret = await crypto.deriveSharedSecret(myPrivKey, message.data['public_key']);
final decryptedText = await crypto.decryptMessage(message.data['content'], sharedSecret);
notificationText = decryptedText;
}
} catch (e) {
print('Decryption failed: $e');
notificationText = 'Failed to decrypt: ${e.toString()}';
}
// 4. Показываем локальное уведомление
await flutterLocalNotificationsPlugin.show(
message.hashCode,
message.data['username'] ?? 'Unknown',
notificationText,
const NotificationDetails(android: AndroidNotificationDetails('chat_id', 'Messages')),
payload: jsonEncode({
'type': 'enc_message',
'sender_id': message.data['sender_id'],
}),
);
print('Notification shown successfully');
} catch (e) {
print('Error processing background message: $e');
}
} else {
print('Message type is not enc_message: ${message.data['type']}');
}
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@ -35,6 +259,7 @@ class MyApp extends StatelessWidget {
themeAnimationCurve: Curves.easeInOut,
theme: themeProvider.themeData,
themeMode: themeProvider.themeMode,
navigatorKey: navigatorKey,
// Начальный экран
home: const SplashScreen(),

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import '/data/models/message_model.dart';
import '/data/models/contact_model.dart';
@ -5,11 +7,13 @@ import 'package:chepuhagram/presentation/widgets/message_bubble.dart';
import 'package:chepuhagram/data/repositories/contact_repository.dart';
import 'package:chepuhagram/domain/services/crypto_service.dart';
import 'package:chepuhagram/data/datasources/ws_client.dart';
import 'dart:convert';
import 'package:provider/provider.dart';
import '/logic/contact_provider.dart';
import '../../domain/services/api_service.dart';
import 'package:chepuhagram/data/datasources/local_db_service.dart';
import 'package:chepuhagram/main.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'contacts_screen.dart';
class ChatScreen extends StatefulWidget {
final Contact contact;
@ -21,6 +25,7 @@ class ChatScreen extends StatefulWidget {
}
class _ChatScreenState extends State<ChatScreen> {
static const String _notificationLaunchKey = 'notification_launch_data';
int myId = 0;
late Contact _currentContact;
bool _isKeyLoading = false;
@ -29,32 +34,41 @@ class _ChatScreenState extends State<ChatScreen> {
final apiService = ApiService();
final CryptoService _cryptoService = CryptoService();
List<MessageModel> messages = [];
StreamSubscription<dynamic>? _socketSubscription;
@override
void initState() {
super.initState();
_currentContact = widget.contact;
currentActiveChatContactId = _currentContact.id; // Устанавливаем активный чат
final contactProvider = context.read<ContactProvider>();
myId = contactProvider.getCurrentUserId() ?? 0;
_loadHistory();
// Если ключа нет, загружаем его при входе
if (_currentContact.publicKey == null) {
_loadContactKey();
}
_loadHistory();
final socketService = Provider.of<SocketService>(context, listen: false);
_socketSubscription = socketService.messages.listen(_handleIncomingMessage);
}
Future<void> _loadContactKey() async {
if (!mounted) return;
setState(() => _isKeyLoading = true);
try {
final updatedContact = await _contactRepository.fetchContactById(
_currentContact.id,
);
if (!mounted) return;
setState(() {
_currentContact = updatedContact;
_isKeyLoading = false;
});
print(updatedContact.publicKey);
} catch (e) {
if (!mounted) return;
setState(() => _isKeyLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
@ -66,6 +80,8 @@ class _ChatScreenState extends State<ChatScreen> {
@override
void dispose() {
currentActiveChatContactId = null; // Сбрасываем активный чат
_socketSubscription?.cancel();
_controller.dispose();
super.dispose();
}
@ -73,7 +89,21 @@ class _ChatScreenState extends State<ChatScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(_currentContact.name)),
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const ContactsScreen()),
);
}
},
),
title: Text(_currentContact.name),
),
body: Column(
children: [
Expanded(
@ -146,11 +176,17 @@ class _ChatScreenState extends State<ChatScreen> {
sharedSecret,
);
final encryptedText50 = await _cryptoService.encryptMessage(
rawText.length > 50 ? rawText.substring(0, 50) : rawText,
sharedSecret,
);
// Формируем payload для сервера
final payload = {
"type": "private_message",
"receiver_id": _currentContact.id,
"content": encryptedText,
"content50": encryptedText50,
};
// Отправляем
@ -180,16 +216,6 @@ class _ChatScreenState extends State<ChatScreen> {
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Подписываемся на поток сообщений из сокета
final socketService = Provider.of<SocketService>(context, listen: false);
socketService.messages.listen((rawData) {
_handleIncomingMessage(rawData);
});
}
void _handleIncomingMessage(Map<String, dynamic> data) async {
if (data['type'] == 'private_message') {
@ -214,6 +240,7 @@ class _ChatScreenState extends State<ChatScreen> {
// 4. Добавляем в список и обновляем экран
await LocalDbService().saveMessages([data]);
if (!mounted) return;
setState(() {
messages.add(
MessageModel(
@ -238,6 +265,11 @@ class _ChatScreenState extends State<ChatScreen> {
}
Future<void> _loadHistory() async {
initialMessage = null; // Сбрасываем данные уведомления при загрузке ключа
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_notificationLaunchKey);
await prefs.setString(_notificationLaunchKey, ''); // Очищаем данные уведомления при загрузке ключа
try {
final myPrivKey = await _cryptoService.getPrivateKey();
final sharedSecret = await _cryptoService.deriveSharedSecret(
@ -265,6 +297,7 @@ class _ChatScreenState extends State<ChatScreen> {
);
}
if (cached.isNotEmpty) {
if (!mounted) return;
setState(() {
messages = loadedLocalMessages;
_isKeyLoading = false;
@ -275,14 +308,15 @@ class _ChatScreenState extends State<ChatScreen> {
}
final history = await apiService.getChatHistory(widget.contact.id);
print(history);
List<MessageModel> loadedMessages = [];
for (var msg in history) {
final decrypted = await _cryptoService.decryptMessage(
msg['content'],
sharedSecret,
);
loadedMessages.add(
loadedMessages.insert(
0,
MessageModel(
text: decrypted,
isMe: msg['sender_id'] == myId,
@ -292,14 +326,20 @@ class _ChatScreenState extends State<ChatScreen> {
),
);
}
await localDb.saveMessages(history);
try {
await localDb.saveMessages(history);
} catch (e) {
print("Ошибка сохранения истории в локальную базу: $e");
}
if (!mounted) return;
setState(() {
messages = loadedMessages;
_isKeyLoading = false;
});
} catch (e) {
print("Ошибка загрузки истории: $e");
if (!mounted) return;
setState(() => _isKeyLoading = false);
}
}

View File

@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:chepuhagram/domain/services/aPI_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../widgets/contact_tile.dart';
@ -6,28 +8,249 @@ import '../screens/new_chat_screen.dart';
import '../screens/chat_screen.dart';
import '/logic/contact_provider.dart';
import '/logic/auth_provider.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:chepuhagram/domain/services/crypto_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:chepuhagram/main.dart';
class ContactsScreen extends StatefulWidget {
const ContactsScreen({super.key});
final int? targetChatId;
const ContactsScreen({super.key, this.targetChatId});
@override
State<ContactsScreen> createState() => _ContactsScreenState();
}
class _ContactsScreenState extends State<ContactsScreen> {
static const String _notificationLaunchKey = 'notification_launch_data';
@override
void initState() {
super.initState();
print(
'ContactsScreen initState, targetChatId: ${widget.targetChatId}',
);
_setupPushNotifications();
WidgetsBinding.instance.addPostFrameCallback((_) {
final authProvider = context.read<AuthProvider>();
final contactProvider = context.read<ContactProvider>();
// Установить текущего пользователя и загрузить контакты с сообщениями
contactProvider.setCurrentUserId(authProvider.currentUserId);
contactProvider.loadContacts();
contactProvider.loadContacts().then((_) {
print(
'Contacts loaded, checking targetChatId: ${widget.targetChatId}',
);
// После загрузки контактов проверить, нужно ли перейти к чату
if (widget.targetChatId != null) {
_navigateToTargetChat();
} else {
_checkSavedNotificationTarget();
}
});
});
}
Future<void> _checkSavedNotificationTarget() async {
final prefs = await SharedPreferences.getInstance();
final savedData = prefs.getString(_notificationLaunchKey);
if (savedData == null) {
print('No saved notification data found in SharedPreferences');
return;
}
try {
final data = jsonDecode(savedData) as Map<String, dynamic>;
print('Recovered saved notification data: $data');
final senderId = int.tryParse(data['sender_id']?.toString() ?? '');
final type = data['type']?.toString();
// Поддерживаем старый payload (только sender_id) и новый (type+sender_id)
if (senderId != null && (type == null || type == 'enc_message')) {
print('Recovered targetChatId from saved data: $senderId');
await prefs.remove(_notificationLaunchKey);
_navigateToTargetChatWithId(senderId);
return;
}
print('Saved notification data is not a valid payload: $data');
await prefs.remove(_notificationLaunchKey);
} catch (e) {
print('Error parsing saved notification data: $e');
await prefs.remove(_notificationLaunchKey);
}
}
void _navigateToTargetChat() {
if (widget.targetChatId == null) return;
_navigateToTargetChatWithId(widget.targetChatId!);
}
void _navigateToTargetChatWithId(int targetChatId) {
print(
'_navigateToTargetChat called with targetChatId: $targetChatId',
);
final contactProvider = context.read<ContactProvider>();
try {
final contact = contactProvider.contacts.firstWhere(
(c) => c.id == targetChatId,
);
print(
'Auto-navigating to chat with contact: ${contact.username}',
);
currentActiveChatContactId = targetChatId; // Устанавливаем активный чат
Navigator.push(
context,
MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)),
);
} catch (e) {
print('Target contact with id $targetChatId not found: $e');
}
}
Future<void> _setupPushNotifications() async {
// Request permissions
await FirebaseMessaging.instance.requestPermission();
String? token = await FirebaseMessaging.instance.getToken();
if (token != null) {
ApiService apiService = ApiService();
print(token);
await apiService.updateFcmToken(token);
}
// Listen for token refresh
FirebaseMessaging.instance.onTokenRefresh.listen((newToken) {
ApiService apiService = ApiService();
apiService.updateFcmToken(newToken);
print('FCM Token refreshed: $newToken');
});
// Listen for foreground messages
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
print('Foreground message received: ${message.data}');
if (message.data['type'] == 'enc_message') {
_handleIncomingMessage(message);
}
});
// Handle notification tap when app was terminated/backgrounded
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
print('Notification tapped, app opened: ${message.data}');
if (message.data['type'] == 'enc_message') {
final senderId = int.tryParse(
message.data['sender_id']?.toString() ?? '',
);
if (senderId != null) {
_navigateToChatFromNotification(senderId);
} else {
print(
'Notification tap contains invalid sender_id: ${message.data['sender_id']}',
);
}
}
});
}
void _navigateToChatFromNotification(int senderId) {
final contactProvider = context.read<ContactProvider>();
print(
'Navigate to chat from notification with senderId: $senderId',
);
// Если контакты еще не загружены, ждем их загрузки
if (contactProvider.contacts.isEmpty) {
print('Contacts not loaded yet, waiting...');
// Ждем немного и пробуем снова
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
_navigateToChatFromNotification(senderId);
}
});
return;
}
try {
final contact = contactProvider.contacts.firstWhere(
(c) => c.id == senderId,
);
print(
'Navigating to chat from notification: ${contact.username}',
);
currentActiveChatContactId = senderId; // Устанавливаем активный чат
Navigator.push(
context,
MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)),
);
} catch (e) {
// Contact not found, stay on contacts screen
print('Contact not found for notification: $senderId');
}
}
Future<void> _handleIncomingMessage(RemoteMessage message) async {
try {
// Проверяем, не находимся ли мы уже в чате с отправителем
final senderId = int.tryParse(
message.data['sender_id']?.toString() ?? '',
);
if (senderId != null && currentActiveChatContactId == senderId) {
print('Already in chat with sender $senderId, skipping notification');
return;
}
// Ensure notification channel exists
const AndroidNotificationChannel channel = AndroidNotificationChannel(
'chat_id',
'Messages',
description: 'Chat messages notifications',
importance: Importance.high,
);
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(channel);
final crypto = CryptoService();
final myPrivKey = await crypto.getPrivateKey();
if (myPrivKey == null) {
print('Private key not found, cannot decrypt message');
return;
}
final sharedSecret = await crypto.deriveSharedSecret(
myPrivKey,
message.data['public_key'],
);
final decryptedText = await crypto.decryptMessage(
message.data['content'],
sharedSecret,
);
// Show local notification
await flutterLocalNotificationsPlugin.show(
message.hashCode,
message.data['username'] ?? 'Unknown',
decryptedText,
const NotificationDetails(
android: AndroidNotificationDetails('chat_id', 'Messages'),
),
payload: jsonEncode({
'type': 'enc_message',
'sender_id': message.data['sender_id'],
}),
);
} catch (e) {
print('Error processing foreground message: $e');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -91,7 +314,11 @@ class _ContactsScreenState extends State<ContactsScreen> {
accountEmail: Text("@ArturKarasevich"),
currentAccountPicture: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.onSurface,
child: Icon(Icons.person, size: 40, color: Theme.of(context).colorScheme.primaryContainer,),
child: Icon(
Icons.person,
size: 40,
color: Theme.of(context).colorScheme.primaryContainer,
),
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,

View File

@ -1,3 +1,4 @@
import 'package:chepuhagram/presentation/screens/contacts_screen.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../logic/auth_provider.dart';
@ -98,7 +99,10 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
if (mounted) {
// Возвращаемся на главный экран
Navigator.of(context).popUntil((route) => route.isFirst);
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const ContactsScreen()),
);
}
} catch (e) {
if (mounted) {

View File

@ -1,10 +1,16 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../logic/auth_provider.dart';
import '../../logic/contact_provider.dart';
import 'login_screen.dart';
import 'contacts_screen.dart';
import 'account_setup_screen.dart';
import 'key_recovery_screen.dart';
import 'chat_screen.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:chepuhagram/main.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@ -14,12 +20,36 @@ class SplashScreen extends StatefulWidget {
}
class _SplashScreenState extends State<SplashScreen> {
int? _targetChatId;
// Ключ для SharedPreferences
static const String _notificationLaunchKey = 'notification_launch_data';
@override
void initState() {
super.initState();
print('SplashScreen initState');
_setupNotificationHandler();
_initializeApp();
}
void _setupNotificationHandler() {
print('Setting up notification handler');
// Обработка открытия приложения из уведомления
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
print('App opened from notification: ${message.data}');
if (message.data['type'] == 'enc_message') {
final senderId = int.tryParse(message.data['sender_id']?.toString() ?? '');
if (senderId != null) {
setState(() {
_targetChatId = senderId;
});
print('Set target chat from opened app: $senderId');
}
}
});
}
Future<void> _initializeApp() async {
// 1. Искусственная задержка в 2 секунды для демонстрации splash
await Future.delayed(const Duration(seconds: 2));
@ -51,9 +81,78 @@ class _SplashScreenState extends State<SplashScreen> {
);
} else {
// Путь Б: Нормальный вход в контакты
// Проверяем, было ли приложение запущено из уведомления
int? targetChatId = _targetChatId; // Сначала проверяем из onMessageOpenedApp
// Если не установлено, проверяем SharedPreferences
if (targetChatId == null) {
final prefs = await SharedPreferences.getInstance();
final savedData = prefs.getString(_notificationLaunchKey);
if (savedData != null) {
try {
final data = jsonDecode(savedData) as Map<String, dynamic>;
print('Found saved notification data: $data');
final senderId = int.tryParse(data['sender_id']?.toString() ?? '');
final type = data['type']?.toString();
// Поддерживаем старый payload (только sender_id) и новый (type+sender_id)
if (senderId != null && (type == null || type == 'enc_message')) {
targetChatId = senderId;
print('App launched from saved notification, target chat: $targetChatId');
}
// Очищаем сохраненные данные после использования
await prefs.remove(_notificationLaunchKey);
} catch (e) {
print('Error parsing saved notification data: $e');
await prefs.remove(_notificationLaunchKey);
}
}
// Также проверяем initialMessage как fallback
if (targetChatId == null) {
print('Checking initialMessage: $initialMessage');
if (initialMessage != null) {
print('Initial message data: ${initialMessage!.data}');
if (initialMessage!.data['type'] == 'enc_message') {
targetChatId = int.tryParse(initialMessage!.data['sender_id']?.toString() ?? '');
print('Set target chat from initialMessage: $targetChatId');
} else {
print('Initial message type is not enc_message: ${initialMessage!.data['type']}');
}
} else {
print('No initial message found');
}
}
} else {
print('Using targetChatId from onMessageOpenedApp: $targetChatId');
}
if (targetChatId != null) {
print('Notification targetChatId resolved: $targetChatId, trying to open chat directly');
try {
final contactProvider = context.read<ContactProvider>();
contactProvider.setCurrentUserId(authProvider.currentUserId);
await contactProvider.loadContacts();
final contact = contactProvider.contacts.firstWhere((c) => c.id == targetChatId);
currentActiveChatContactId = targetChatId;
print('Directly navigating to ChatScreen for contact: ${contact.username}');
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)),
);
return;
} catch (e) {
print('Failed to open chat directly, falling back to ContactsScreen: $e');
}
}
print('Navigating to ContactsScreen, targetChatId: $targetChatId');
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const ContactsScreen()),
MaterialPageRoute(builder: (_) => ContactsScreen(targetChatId: targetChatId)),
);
}
} else {
@ -102,7 +201,7 @@ class _SplashScreenState extends State<SplashScreen> {
fontSize: 12,
),
),
const SizedBox(height: 80),
const SizedBox(height: 40),
],
),
),

View File

@ -5,12 +5,22 @@
import FlutterMacOS
import Foundation
import firebase_analytics
import firebase_core
import firebase_messaging
import flutter_local_notifications
import flutter_secure_storage_darwin
import path_provider_foundation
import shared_preferences_foundation
import sqflite_darwin
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAnalyticsPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
}

View File

@ -1,6 +1,14 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_flutterfire_internals:
dependency: transitive
description:
name: _flutterfire_internals
sha256: "37a42d06068e2fe3deddb2da079a8c4d105f241225ba27b7122b37e9865fd8f7"
url: "https://pub.dev"
source: hosted
version: "1.3.35"
args:
dependency: transitive
description:
@ -73,6 +81,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.9"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.12"
fake_async:
dependency: transitive
description:
@ -89,6 +105,86 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
firebase_analytics:
dependency: "direct main"
description:
name: firebase_analytics
sha256: dbf1e7ab22cfb1f4a4adb103b46a26276b4edc593d4a78ef6fb942bafc92e035
url: "https://pub.dev"
source: hosted
version: "10.10.7"
firebase_analytics_platform_interface:
dependency: transitive
description:
name: firebase_analytics_platform_interface
sha256: "3729b74f8cf1d974a27ba70332ecb55ff5ff560edc8164a6469f4a055b429c37"
url: "https://pub.dev"
source: hosted
version: "3.10.8"
firebase_analytics_web:
dependency: transitive
description:
name: firebase_analytics_web
sha256: "019cd7eee74254d33fbd2e29229367ce33063516bf6b3258a341d89e3b0f1655"
url: "https://pub.dev"
source: hosted
version: "0.5.7+7"
firebase_core:
dependency: "direct main"
description:
name: firebase_core
sha256: "26de145bb9688a90962faec6f838247377b0b0d32cc0abecd9a4e43525fc856c"
url: "https://pub.dev"
source: hosted
version: "2.32.0"
firebase_core_platform_interface:
dependency: transitive
description:
name: firebase_core_platform_interface
sha256: "8bcfad6d7033f5ea951d15b867622a824b13812178bfec0c779b9d81de011bbb"
url: "https://pub.dev"
source: hosted
version: "5.4.2"
firebase_core_web:
dependency: transitive
description:
name: firebase_core_web
sha256: eb3afccfc452b2b2075acbe0c4b27de62dd596802b4e5e19869c1e926cbb20b3
url: "https://pub.dev"
source: hosted
version: "2.24.0"
firebase_messaging:
dependency: "direct main"
description:
name: firebase_messaging
sha256: "980259425fa5e2afc03e533f33723335731d21a56fd255611083bceebf4373a8"
url: "https://pub.dev"
source: hosted
version: "14.7.10"
firebase_messaging_platform_interface:
dependency: transitive
description:
name: firebase_messaging_platform_interface
sha256: "87c4a922cb6f811cfb7a889bdbb3622702443c52a0271636cbc90d813ceac147"
url: "https://pub.dev"
source: hosted
version: "4.5.37"
firebase_messaging_web:
dependency: transitive
description:
name: firebase_messaging_web
sha256: "90dc7ed885e90a24bb0e56d661d4d2b5f84429697fd2cbb9e5890a0ca370e6f4"
url: "https://pub.dev"
source: hosted
version: "3.5.18"
flutter:
dependency: "direct main"
description: flutter
@ -102,6 +198,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35"
url: "https://pub.dev"
source: hosted
version: "17.2.4"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af
url: "https://pub.dev"
source: hosted
version: "4.0.1"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66"
url: "https://pub.dev"
source: hosted
version: "7.2.0"
flutter_secure_storage:
dependency: "direct main"
description:
@ -192,6 +312,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.1"
js:
dependency: transitive
description:
name: js
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.dev"
source: hosted
version: "0.6.7"
jwt_decoder:
dependency: "direct main"
description:
@ -328,6 +456,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
@ -352,6 +488,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
url: "https://pub.dev"
source: hosted
version: "2.4.23"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
@ -453,6 +645,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.7"
timezone:
dependency: transitive
description:
name: timezone
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
typed_data:
dependency: transitive
description:
@ -517,6 +717,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
sdks:
dart: ">=3.10.0 <4.0.0"
flutter: ">=3.38.0"

View File

@ -42,6 +42,11 @@ dependencies:
cryptography: ^2.5.0
sqflite: ^2.3.0
path: ^1.9.0
firebase_core: ^2.24.2
firebase_messaging: ^14.7.10
flutter_local_notifications: ^17.2.2
firebase_analytics: ^10.10.7
shared_preferences: ^2.5.5
dev_dependencies:
flutter_test:

View File

@ -0,0 +1,36 @@
"""empty message
Revision ID: b577fae9f973
Revises: 4e1aa78f81c6
Create Date: 2026-04-24 23:01:14.120776
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b577fae9f973'
down_revision: Union[str, Sequence[str], None] = '4e1aa78f81c6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.add_column(sa.Column('fcm_token', sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.drop_column('fcm_token')
# ### end Alembic commands ###

View File

@ -6,7 +6,6 @@ from app.api import schemas
from app.db import models
from jose import JWTError, jwt
from app.core.security import get_current_user
# бд
@ -105,4 +104,12 @@ async def setup_account(data: schemas.SetupAccount, current_user: models.User =
user_to_update.encrypted_private_key = data.encrypted_private_key
db.commit()
db.refresh(user_to_update)
return {"status": "ok", "message": "Account setup completed"}
return {"status": "ok", "message": "Account setup completed"}
@authRouter.post("/update-fcm")
async def update_fcm(token: str, current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
user_to_update = db.merge(current_user)
user_to_update.fcm_token = token
db.commit()
db.refresh(user_to_update)
return {"status": "ok"}

View File

@ -29,7 +29,7 @@ async def get_chat_history(
messages = db.query(models.Message).filter(
(models.Message.sender_id == current_user.id) & (models.Message.receiver_id == contact_id) |
(models.Message.sender_id == contact_id) & (models.Message.receiver_id == current_user.id)
).order_by(models.Message.timestamp.asc()).limit(limit).all()
).order_by(models.Message.timestamp.desc()).limit(limit).all()
return messages

View File

@ -22,6 +22,7 @@ class User(Base):
hashed_password = Column(String)
public_key = Column(String, nullable=True)
encrypted_private_key = Column(String, nullable=True)
fcm_token = Column(String, nullable=True)
class Message(Base):
__tablename__ = "messages"

View File

@ -5,9 +5,15 @@ from datetime import datetime
import json
from sqlalchemy.orm import Session
from app.db import models
from firebase_admin import messaging, credentials, exceptions
import firebase_admin
cred = credentials.Certificate("chepuhagram-6ca5d-firebase-adminsdk-fbsvc-cf8a5ad2f3.json")
firebase_admin.initialize_app(cred)
# бд
def get_db():
db = models.SessionLocal()
try:
@ -40,10 +46,13 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
data = await websocket.receive_text()
message_data = json.loads(data)
print(f"DEBUG: Получены данные: {message_data}")
if message_data.get("type") == "private_message":
user = db.query(models.User).filter(models.User.id == user_id).first()
receiver_id = message_data.get("receiver_id")
content = message_data.get("content")
content50 = message_data.get("content50")
new_msg = models.Message(
sender_id=user_id,
receiver_id=receiver_id,
@ -52,11 +61,24 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
db.add(new_msg)
db.commit()
db.refresh(new_msg)
if receiver_id not in manager.active_connections and user.public_key != '':
receiver = db.query(models.User).filter(
models.User.id == receiver_id).first()
if receiver.fcm_token:
send_fcm_notification(
receiver.fcm_token,
user_id,
user.first_name,
user.public_key,
content50 if content50 else content,
datetime.now(),
)
# Формируем пакет для получателя
outgoing_message = {
"id": new_msg.id,
"type": "private_message",
"sender_id": user_id,
"sender_id": user_id,
"reciever_id": receiver_id,
"content": message_data.get("content"),
"timestamp": datetime.now().isoformat()
@ -70,6 +92,29 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
manager.disconnect(user_id)
def send_fcm_notification(token, user_id, username, public_key, encrypted_text, timestamp):
print(f"DEBUG: Отправляем FCM уведомление пользователю {user_id} с токеном {token}")
message = messaging.Message(
data={
"type": "enc_message",
"sender_id": str(user_id),
"username": username,
"public_key": public_key,
"content": encrypted_text, # Зашифрованный текст
"timestamp": timestamp.isoformat(),
},
android=messaging.AndroidConfig(
priority='high',
),
token=token,
)
try:
response = messaging.send(message)
print('Successfully sent message:', response)
except Exception as e:
print('Unexpected error sending push:', e)
class ConnectionManager:
def __init__(self):
# Храним активные соединения: {user_id: websocket}

View File

@ -6,9 +6,12 @@
#include "generated_plugin_registrant.h"
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FirebaseCorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
}

View File

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
firebase_core
flutter_secure_storage_windows
)