570 lines
21 KiB
Dart
570 lines
21 KiB
Dart
import 'package:chepuhagram/data/datasources/ws_client.dart';
|
||
import 'package:chepuhagram/logic/auth_provider.dart';
|
||
import 'package:chepuhagram/logic/contact_provider.dart';
|
||
import 'package:chepuhagram/core/theme_manager.dart';
|
||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||
import 'dart:io';
|
||
import 'package:firebase_core/firebase_core.dart';
|
||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||
import 'package:path_provider/path_provider.dart';
|
||
import 'package:chepuhagram/domain/services/crypto_service.dart';
|
||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||
import 'dart:convert';
|
||
import 'package:chepuhagram/data/datasources/local_db_service.dart';
|
||
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 'package:path/path.dart' as p;
|
||
import 'package:chepuhagram/presentation/screens/splash_screen.dart';
|
||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||
import 'package:chepuhagram/presentation/screens/call_screen.dart';
|
||
import 'package:flutter_callkit_incoming/entities/entities.dart';
|
||
import 'package:timeago/timeago.dart' as timeago;
|
||
import 'package:timeago/src/messages/ru_messages.dart';
|
||
|
||
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
|
||
FlutterLocalNotificationsPlugin();
|
||
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||
|
||
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
|
||
|
||
int? currentActiveChatContactId;
|
||
|
||
RemoteMessage? initialMessage;
|
||
|
||
// Ключ для SharedPreferences
|
||
const String _notificationLaunchKey = 'notification_launch_data';
|
||
const String _lastHandledNotificationLaunchPayloadKey =
|
||
'notification_last_handled_payload';
|
||
|
||
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();
|
||
final canonicalPayload = jsonEncode(data);
|
||
|
||
if (context == null) {
|
||
final lastHandled = prefs.getString(
|
||
_lastHandledNotificationLaunchPayloadKey,
|
||
);
|
||
if (lastHandled != canonicalPayload) {
|
||
await prefs.setString(_notificationLaunchKey, canonicalPayload);
|
||
await prefs.setString(
|
||
_lastHandledNotificationLaunchPayloadKey,
|
||
canonicalPayload,
|
||
);
|
||
}
|
||
print(
|
||
'Navigator context is null, saved notification payload to SharedPreferences',
|
||
);
|
||
} else {
|
||
await prefs.remove(_notificationLaunchKey);
|
||
}
|
||
|
||
_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,
|
||
);
|
||
|
||
// ВАЖНОЕ ИЗМЕНЕНИЕ:
|
||
// Независимо от того, загружены контакты или нет, на десктопе/планшете мы ВСЕГДА
|
||
// сбрасываем навигацию до экрана ContactsScreen, передавая ему targetChatId.
|
||
// Это заставит приложение открыть чат в его родном правом окошке, сохранив боковую панель.
|
||
|
||
currentActiveChatContactId = senderId; // Устанавливаем активный чат
|
||
|
||
// Очищаем весь стек экранов и открываем ContactsScreen со встроенным параметром чата
|
||
Navigator.pushAndRemoveUntil(
|
||
context,
|
||
MaterialPageRoute(builder: (_) => ContactsScreen(targetChatId: senderId)),
|
||
(route) => false, // Удаляет все предыдущие полноэкранные оверлеи
|
||
);
|
||
} else {
|
||
print('Navigator context is null');
|
||
}
|
||
}
|
||
|
||
bool firebaseInitialized = false;
|
||
|
||
void main() async {
|
||
// Initialize timeago for Russian locale
|
||
timeago.setLocaleMessages('ru', timeago.RuMessages());
|
||
|
||
WidgetsFlutterBinding.ensureInitialized();
|
||
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
|
||
sqfliteFfiInit();
|
||
|
||
// ИСПОЛЬЗУЕМ СУППОРТ-ДИРЕКТОРИЮ (App Data / Roaming) вместо Документов
|
||
final appSupportDir = await getApplicationSupportDirectory();
|
||
|
||
// Создаем подпапку внутри AppData, там Windows никогда не выдаст ошибку 2
|
||
final sqfliteDbPath = p.join(appSupportDir.path, 'sqflite_databases');
|
||
await Directory(sqfliteDbPath).create(recursive: true);
|
||
|
||
// Привязываем фабрику sqflite к новому безопасному пути
|
||
databaseFactory = databaseFactoryFfi;
|
||
await databaseFactory.setDatabasesPath(sqfliteDbPath);
|
||
|
||
print('Безопасный путь для SQFlite на Windows: $sqfliteDbPath');
|
||
}
|
||
try {
|
||
print('Initializing LocalDbService in main...');
|
||
final db = LocalDbService();
|
||
print('База данных сохранена по пути: ${db.connection.connectionData}');
|
||
print('LocalDbService initialized successfully.');
|
||
} catch (e, st) {
|
||
print('LocalDbService init failed in main: $e');
|
||
print(st);
|
||
}
|
||
|
||
if (Platform.isAndroid || Platform.isIOS) {
|
||
await Firebase.initializeApp();
|
||
firebaseInitialized = true;
|
||
|
||
initialMessage = await FirebaseMessaging.instance.getInitialMessage();
|
||
print('Initial message from main(): $initialMessage');
|
||
} else {
|
||
print('Skipping Firebase initialization on desktop.');
|
||
}
|
||
|
||
// Сохраняем информацию в 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']}');
|
||
|
||
final payloadString = jsonEncode(initialMessage!.data);
|
||
final lastHandled = prefs.getString(
|
||
_lastHandledNotificationLaunchPayloadKey,
|
||
);
|
||
if (lastHandled != payloadString) {
|
||
// Сохраняем данные уведомления
|
||
await prefs.setString(_notificationLaunchKey, payloadString);
|
||
await prefs.setString(
|
||
_lastHandledNotificationLaunchPayloadKey,
|
||
payloadString,
|
||
);
|
||
print('Saved notification data to SharedPreferences');
|
||
} else {
|
||
print('InitialMessage payload already handled earlier, skipping');
|
||
}
|
||
} else {
|
||
print('No initial message - app launched normally');
|
||
// Очищаем сохраненные данные, если приложение запущено нормально
|
||
await prefs.remove(_notificationLaunchKey);
|
||
}
|
||
|
||
const AndroidInitializationSettings initializationSettingsAndroid =
|
||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||
const WindowsInitializationSettings initializationSettingsWindows =
|
||
WindowsInitializationSettings(
|
||
appName: 'Chepuhagram',
|
||
appUserModelId: 'ru.ArturKarasevich.Chepuhagram',
|
||
guid: '6c0af055-e0b5-4f10-9aed-c12dc078f949',
|
||
);
|
||
final InitializationSettings initializationSettings = InitializationSettings(
|
||
android: initializationSettingsAndroid,
|
||
windows: initializationSettingsWindows,
|
||
);
|
||
await flutterLocalNotificationsPlugin.initialize(
|
||
settings: 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 lastHandled = prefs.getString(
|
||
_lastHandledNotificationLaunchPayloadKey,
|
||
);
|
||
if (lastHandled != payload) {
|
||
final data = jsonDecode(payload);
|
||
await prefs.setString(_notificationLaunchKey, jsonEncode(data));
|
||
await prefs.setString(
|
||
_lastHandledNotificationLaunchPayloadKey,
|
||
payload,
|
||
);
|
||
print('Saved local notification launch payload to SharedPreferences');
|
||
} else {
|
||
print('Local notification payload already handled earlier, skipping');
|
||
}
|
||
} 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);
|
||
|
||
if (firebaseInitialized) {
|
||
FirebaseMessaging.onBackgroundMessage(
|
||
_firebaseMessagingBackgroundHandler,
|
||
);
|
||
}
|
||
}
|
||
|
||
if (Platform.isAndroid || Platform.isIOS) {
|
||
initCallkitListener();
|
||
} else {
|
||
print('Skipping CallKit listener on desktop platform.');
|
||
}
|
||
|
||
runApp(
|
||
MultiProvider(
|
||
providers: [
|
||
Provider(create: (_) => CryptoService()),
|
||
Provider(create: (_) => SocketService()),
|
||
ChangeNotifierProvider(create: (_) => AuthProvider()),
|
||
ChangeNotifierProvider(create: (_) => ThemeProvider()),
|
||
Provider(create: (_) => SocketService()),
|
||
|
||
ChangeNotifierProvider(
|
||
create: (context) => ContactProvider(context.read<CryptoService>()),
|
||
),
|
||
],
|
||
child: const MyApp(),
|
||
),
|
||
);
|
||
}
|
||
|
||
void initCallkitListener() {
|
||
if (!(Platform.isAndroid || Platform.isIOS)) {
|
||
print('Skipping CallKit event listener on non-mobile platform.');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
FlutterCallkitIncoming.onEvent.listen((event) {
|
||
if (event == null) return;
|
||
|
||
switch (event.event) {
|
||
case Event.actionCallIncoming:
|
||
// Звонок получен, но CallKit уже показал экран.
|
||
// Здесь можно логировать или обновить статус в БД.
|
||
print("Incoming call: ${event.body['id']}");
|
||
break;
|
||
|
||
case Event.actionCallStart:
|
||
// Исходящий звонок начат
|
||
break;
|
||
|
||
case Event.actionCallAccept:
|
||
// ПОЛЬЗОВАТЕЛЬ НАЖАЛ "ПРИНЯТЬ"
|
||
// 1. Уведомляем сервер (чтобы другая сторона узнала о начале звонка)
|
||
SocketService().sendMessage({
|
||
"type": "call_accept",
|
||
"call_id": event.body['id'],
|
||
});
|
||
|
||
// 2. Переходим на экран звонка
|
||
navigatorKey.currentState?.push(
|
||
MaterialPageRoute(
|
||
builder: (_) => CallScreen(
|
||
callId: event.body['id'],
|
||
isIncoming: true,
|
||
callerName: event.body['nameCaller'] ?? 'Unknown',
|
||
onAccept: () {},
|
||
onHangup: () => _handleHangupGlobal(event.body['id']),
|
||
),
|
||
),
|
||
);
|
||
break;
|
||
|
||
case Event.actionCallDecline:
|
||
// ПОЛЬЗОВАТЕЛЬ НАЖАЛ "ОТКЛОНИТЬ"
|
||
SocketService().sendMessage({
|
||
"type": "decline",
|
||
"call_id": event.body['id'],
|
||
});
|
||
break;
|
||
|
||
case Event.actionCallEnded:
|
||
case Event.actionCallTimeout:
|
||
// Звонок завершен или пропущен
|
||
print("Call ended or timeout");
|
||
break;
|
||
|
||
default:
|
||
print("Event unhandled: ${event.event}");
|
||
break;
|
||
}
|
||
});
|
||
} catch (e, st) {
|
||
print('CallKit listener initialization failed: $e');
|
||
print(st);
|
||
}
|
||
}
|
||
|
||
void _handleHangupGlobal(String callId) {
|
||
SocketService().sendMessage({"type": "hangup", "call_id": callId});
|
||
}
|
||
|
||
@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 WindowsInitializationSettings initializationSettingsWindows =
|
||
WindowsInitializationSettings(
|
||
appName: 'Chepuhagram',
|
||
appUserModelId: 'ru.ArturKarasevich.Chepuhagram',
|
||
guid: '6c0af055-e0b5-4f10-9aed-c12dc078f949',
|
||
);
|
||
final InitializationSettings initializationSettings =
|
||
InitializationSettings(
|
||
android: initializationSettingsAndroid,
|
||
windows: initializationSettingsWindows,
|
||
);
|
||
await flutterLocalNotificationsPlugin.initialize(
|
||
settings: initializationSettings,
|
||
onDidReceiveNotificationResponse: _onSelectNotification,
|
||
);
|
||
|
||
// 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()}';
|
||
}
|
||
|
||
final String senderIdRaw = message.data['sender_id']?.toString() ?? '';
|
||
final senderId = int.tryParse(senderIdRaw);
|
||
|
||
// ==========================================================
|
||
// ОБРАБОТКА ИМЕНИ: КЭШ УСТРОЙСТВА + ДАННЫЕ ОТ СЕРВЕРА
|
||
// ==========================================================
|
||
final prefs = await SharedPreferences.getInstance();
|
||
|
||
// 1. Пытаемся прочитать из локального кэша устройства
|
||
final String? cachedFirstName = prefs.getString('firstname_$senderIdRaw');
|
||
final String? cachedLastName = prefs.getString('lastname_$senderIdRaw');
|
||
|
||
// Список «мусорных» системных строк для фильтрации
|
||
final invalidValues = {'unknown', 'uncnown', 'null', ''};
|
||
|
||
// Функция очистки строк от пробелов и мусора
|
||
String cleanField(String? value) {
|
||
if (value == null) return '';
|
||
final trimmed = value.trim();
|
||
return invalidValues.contains(trimmed.toLowerCase()) ? '' : trimmed;
|
||
}
|
||
|
||
// Очищаем данные из кэша
|
||
String firstName = cleanField(cachedFirstName);
|
||
String lastName = cleanField(cachedLastName);
|
||
|
||
// 2. Если в кэше пусто, берем данные, которые сервер прислал в push-уведомлении
|
||
if (firstName.isEmpty) {
|
||
firstName = cleanField(message.data['firstname']?.toString());
|
||
}
|
||
if (lastName.isEmpty) {
|
||
lastName = cleanField(message.data['lastname']?.toString());
|
||
}
|
||
|
||
// Собираем полное имя
|
||
String title = '$firstName $lastName'.trim();
|
||
|
||
// 3. Если имени нет ни в кэше, ни в полях сервера, берем username
|
||
if (title.isEmpty) {
|
||
title = cleanField(message.data['username']?.toString());
|
||
}
|
||
|
||
// 4. Если вообще всё было "unknown" или пусто — ставим заглушку
|
||
if (title.isEmpty) {
|
||
title = 'Без имени';
|
||
}
|
||
// ==========================================================
|
||
|
||
// 4. Показываем локальное уведомление
|
||
final String groupKey = 'ru.chepuhagram.app.$senderId';
|
||
|
||
await flutterLocalNotificationsPlugin.show(
|
||
id: senderId ?? 0,
|
||
title: '',
|
||
body: '',
|
||
notificationDetails: NotificationDetails(
|
||
android: AndroidNotificationDetails(
|
||
'Messages',
|
||
'Новые сообщения',
|
||
groupKey: groupKey,
|
||
setAsGroupSummary: true,
|
||
importance: Importance.high,
|
||
priority: Priority.high,
|
||
groupAlertBehavior: GroupAlertBehavior.all,
|
||
),
|
||
),
|
||
);
|
||
|
||
await flutterLocalNotificationsPlugin.show(
|
||
id: message.hashCode,
|
||
title: title, // Отвалидированное имя с сервера или из кэша
|
||
body: notificationText,
|
||
notificationDetails: NotificationDetails(
|
||
android: AndroidNotificationDetails(
|
||
'chat_id',
|
||
'Messages',
|
||
groupKey: groupKey,
|
||
importance: Importance.high,
|
||
priority: Priority.high,
|
||
showWhen: true,
|
||
),
|
||
),
|
||
payload: jsonEncode({
|
||
'type': 'enc_message',
|
||
'sender_id': message.data['sender_id'],
|
||
'timestamp':
|
||
message.data['timestamp'] ?? DateTime.now().toIso8601String(),
|
||
}),
|
||
);
|
||
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 StatefulWidget {
|
||
const MyApp({super.key});
|
||
|
||
@override
|
||
State<MyApp> createState() => _MyAppState();
|
||
}
|
||
|
||
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addObserver(this);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
WidgetsBinding.instance.removeObserver(this);
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||
// Закрываем сокет, как только приложение сворачивается.
|
||
if (state == AppLifecycleState.paused ||
|
||
state == AppLifecycleState.inactive ||
|
||
state == AppLifecycleState.detached) {
|
||
try {
|
||
//context.read<AuthProvider>().closeRealtime();
|
||
} catch (_) {}
|
||
return;
|
||
}
|
||
|
||
// На возврате в приложение — пробуем переподключиться (если есть токен).
|
||
if (state == AppLifecycleState.resumed) {
|
||
try {
|
||
context.read<AuthProvider>().initRealtime();
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final themeProvider = context.watch<ThemeProvider>();
|
||
return MaterialApp(
|
||
title: 'Chepuhagram',
|
||
debugShowCheckedModeBanner: false,
|
||
themeAnimationDuration: const Duration(milliseconds: 300),
|
||
themeAnimationCurve: Curves.easeInOut,
|
||
theme: themeProvider.themeData,
|
||
themeMode: themeProvider.themeMode,
|
||
navigatorKey: navigatorKey,
|
||
navigatorObservers: [routeObserver],
|
||
|
||
routes: {'/splash': (context) => const SplashScreen()},
|
||
initialRoute: '/splash',
|
||
);
|
||
}
|
||
}
|