feat(push): FCM via MAX server — oneme flavor, dedicated FCM workflow, opcode 22, notifications + CI/lint fixes

This commit is contained in:
klockky
2026-05-15 18:14:22 +03:00
parent 0a9780a024
commit 75ccc2e694
20 changed files with 647 additions and 203 deletions
+33
View File
@@ -210,6 +210,12 @@ enum AuthRequestType {
enum LoginStatus { idle, loading, success, error }
class WrongDeviceTokenException implements Exception {
const WrongDeviceTokenException();
@override
String toString() => 'WrongDeviceTokenException';
}
class RequestCodeResult {
final String token;
@@ -429,6 +435,33 @@ class AccountModule {
return config;
}
Future<void> registerPushToken(String pushToken) async {
_ensureOnline();
final packet = await _api.sendRequest(Opcode.config, <dynamic, dynamic>{
'pushToken': pushToken,
});
if (packet.isError) {
final msg = messageFromErrorPayload(packet.payload).toUpperCase();
if (msg.contains('WRONG_DEVICE_TOKEN') ||
msg.contains('WRONG.DEVICE.TOKEN')) {
throw const WrongDeviceTokenException();
}
throw PacketError(messageFromErrorPayload(packet.payload));
}
}
Future<void> unregisterPushToken(String pushToken) async {
if (_api.state != SessionState.online) return;
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) return;
final authToken = await TokenStorage.readToken(accountId);
if (authToken == null) return;
await _api.sendRequest(Opcode.logout, <dynamic, dynamic>{
'token': authToken,
'pushToken': pushToken,
});
}
Future<ProfileData> updateProfileName(String firstName, String? lastName) async {
_ensureOnline();
final payload = <dynamic, dynamic>{
+188
View File
@@ -0,0 +1,188 @@
import 'dart:async';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../backend/api.dart';
import '../../backend/modules/account.dart';
import '../utils/logger.dart';
const _channelId = 'komet_messages';
const _channelName = 'Сообщения';
const _prefsTokenKey = 'fcm_push_token';
@pragma('vm:entry-point')
Future<void> _backgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
if (message.notification != null) return;
final plugin = FlutterLocalNotificationsPlugin();
await plugin.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
),
);
await _display(plugin, message);
}
Future<void> _display(
FlutterLocalNotificationsPlugin plugin,
RemoteMessage message,
) async {
final data = message.data;
final title = message.notification?.title ??
data['title']?.toString() ??
data['sender']?.toString() ??
'MAX';
final body = message.notification?.body ??
data['body']?.toString() ??
data['text']?.toString() ??
data['message']?.toString() ??
'Новое сообщение';
await plugin.show(
id: message.messageId?.hashCode ??
DateTime.now().millisecondsSinceEpoch ~/ 1000,
title: title,
body: body,
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
_channelId,
_channelName,
importance: Importance.high,
priority: Priority.high,
),
),
);
}
class PushService {
PushService._();
static final PushService instance = PushService._();
final FlutterLocalNotificationsPlugin _local =
FlutterLocalNotificationsPlugin();
Api? _api;
AccountModule? _account;
String? _token;
bool _initialized = false;
Future<void> init({required Api api, required AccountModule account}) async {
if (_initialized) return;
_api = api;
_account = account;
try {
await Firebase.initializeApp();
} catch (e) {
logger.w('Push: Firebase init не удался: $e');
return;
}
_initialized = true;
await _local.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
),
);
await _local
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(
const AndroidNotificationChannel(
_channelId,
_channelName,
importance: Importance.high,
),
);
final messaging = FirebaseMessaging.instance;
await messaging.requestPermission();
FirebaseMessaging.onBackgroundMessage(_backgroundHandler);
FirebaseMessaging.onMessage.listen((m) {
_display(_local, m);
});
messaging.onTokenRefresh.listen((t) async {
_token = t;
await _persistToken(t);
await _registerWithServer();
});
final prefs = await SharedPreferences.getInstance();
_token = prefs.getString(_prefsTokenKey);
try {
_token = await messaging.getToken() ?? _token;
if (_token != null) await _persistToken(_token!);
logger.i('Push: FCM-токен получен (${_token?.length ?? 0} симв.)');
} catch (e) {
logger.w('Push: getToken не удался: $e');
}
}
Future<void> onLoginSuccess() async {
if (!_initialized) return;
if (_token == null) {
try {
_token = await FirebaseMessaging.instance.getToken();
if (_token != null) await _persistToken(_token!);
} catch (_) {}
}
await _registerWithServer();
}
Future<void> unregister() async {
if (!_initialized || _token == null) return;
final account = _account;
if (account != null) {
try {
await account.unregisterPushToken(_token!);
} catch (e) {
logger.w('Push: unregister не удался: $e');
}
}
try {
await FirebaseMessaging.instance.deleteToken();
} catch (_) {}
_token = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_prefsTokenKey);
}
Future<void> _registerWithServer() async {
final account = _account;
final api = _api;
if (account == null || api == null) return;
if (api.state != SessionState.online) return;
final token = _token;
if (token == null || token.isEmpty) return;
try {
await account.registerPushToken(token);
logger.i('Push: токен зарегистрирован на сервере MAX');
} on WrongDeviceTokenException {
logger.w('Push: WRONG_DEVICE_TOKEN, переполучаю токен');
try {
await FirebaseMessaging.instance.deleteToken();
final fresh = await FirebaseMessaging.instance.getToken();
if (fresh != null && fresh.isNotEmpty) {
_token = fresh;
await _persistToken(fresh);
await account.registerPushToken(fresh);
logger.i('Push: токен перерегистрирован');
}
} catch (e) {
logger.w('Push: повторная регистрация не удалась: $e');
}
} catch (e) {
logger.w('Push: регистрация токена не удалась: $e');
}
}
Future<void> _persistToken(String token) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsTokenKey, token);
}
}
@@ -1085,7 +1085,7 @@ class _ChatListScreenState extends State<ChatListScreen>
child: Text(
'Кажется, тут пусто...',
style: TextStyle(
color: cs.onSurface.withOpacity(0.6),
color: cs.onSurface.withValues(alpha: 0.6),
fontSize: 16,
),
),
@@ -1146,10 +1146,6 @@ class _ChatListScreenState extends State<ChatListScreen>
? ContactCache.get(chat.lastMsgSenderId!)
: null;
final avatar = chat.lastMsgSenderId != null
? ContactCache.getAvatar(chat.lastMsgSenderId!)
: null;
String fullMsg = "";
if (name?.isNotEmpty == true && chat.id != 0) {
@@ -2073,7 +2069,6 @@ Navigator.push(
}
Widget _buildFabMenu() {
final cs = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
@@ -11,7 +11,6 @@ import '../../../backend/modules/messages.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/utils/haptics.dart';
import '../../../models/attachment.dart';
import '../../../backend/modules/messages.dart' show ContactCache;
import '../../widgets/message_bubble.dart';
import '../../widgets/attachment_panel.dart';
@@ -1,4 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/storage/app_database.dart';
@@ -63,6 +62,7 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
);
_avatarUrl = newProfile.baseUrl;
_photoId = newProfile.photoId;
if (!mounted) return;
KometApp.stateOf(context)?.notifyProfileUpdate();
if (mounted) {
showCustomNotification(context, 'Имя сохранено');
@@ -93,6 +93,7 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
final newProfile = await accountModule.removeProfilePhoto(_photoId!);
_avatarUrl = newProfile.baseUrl;
_photoId = newProfile.photoId;
if (!mounted) return;
KometApp.stateOf(context)?.notifyProfileUpdate();
if (mounted) {
showCustomNotification(context, 'Фото удалено');
@@ -15,8 +15,6 @@ class PasswordEntryScreen extends StatefulWidget {
class _PasswordEntryScreenState extends State<PasswordEntryScreen> {
bool _isLoading = true;
bool _is2faEnabled = false;
String? _email;
String? _hint;
@override
void initState() {
@@ -943,7 +943,7 @@ class _SecurityScreenState extends State<SecurityScreen>
fontSize: 14,
),
),
if (trailing != null) trailing,
?trailing,
const SizedBox(width: 4),
Icon(
Symbols.chevron_right,
+14 -164
View File
@@ -1,9 +1,6 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:komet/backend/modules/chats.dart';
import 'package:komet/backend/modules/contacts.dart';
import 'package:komet/main.dart';
import 'package:flutter/foundation.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../backend/modules/messages.dart';
import '../../core/utils/haptics.dart';
@@ -23,16 +20,18 @@ class MessageBubble extends StatelessWidget {
static const double compactTimePadding = 8.0;
bool get _hasPhotoWithCaption {
if (message.attachments == null || message.attachments!.isEmpty)
if (message.attachments == null || message.attachments!.isEmpty) {
return false;
}
final hasPhoto = message.attachments!.any((a) => a is PhotoAttachment);
final hasCaption = message.text != null && message.text!.isNotEmpty;
return hasPhoto && hasCaption;
}
bool get _hasMultiplePhotosNoCaption {
if (message.attachments == null || message.attachments!.isEmpty)
if (message.attachments == null || message.attachments!.isEmpty) {
return false;
}
final photoCount = message.attachments!.whereType<PhotoAttachment>().length;
final hasCaption = message.text != null && message.text!.isNotEmpty;
return photoCount >= 2 && !hasCaption;
@@ -226,7 +225,6 @@ class MessageBubble extends StatelessWidget {
case MessageType.control:
return 4;
}
return 4;
}
double get bottomMargin {
@@ -253,17 +251,6 @@ class MessageBubble extends StatelessWidget {
case BubbleShape.groupedMiddle:
return 1;
}
case MessageType.attachment:
switch (shape) {
case BubbleShape.singleTop:
return 1;
case BubbleShape.singleBottom:
return 1;
case BubbleShape.singleMiddle:
return 4;
case BubbleShape.groupedMiddle:
return 1;
}
case MessageType.voice:
switch (shape) {
case BubbleShape.singleTop:
@@ -278,7 +265,6 @@ class MessageBubble extends StatelessWidget {
case MessageType.control:
return 4;
}
return 4;
}
// Внутренний отступ, размеры типа я хз
@@ -311,7 +297,6 @@ class MessageBubble extends StatelessWidget {
case MessageType.control:
return const EdgeInsets.symmetric(horizontal: 14, vertical: 4);
}
return const EdgeInsets.symmetric(horizontal: 14, vertical: 10);
}
@override
@@ -491,7 +476,7 @@ class MessageBubble extends StatelessWidget {
children: [
Flexible(
child: isForwarded
? _buildForwardedInlineText(context, forwarded!, textColor)
? _buildForwardedInlineText(context, forwarded, textColor)
: Text(
message.text ?? '',
style: TextStyle(color: textColor, fontSize: 16, height: 1.3),
@@ -590,67 +575,15 @@ class MessageBubble extends StatelessWidget {
}
ForwardedMessageAttachment? _getForwardedAttachment() {
if (message.attachments == null || message.attachments!.isEmpty)
if (message.attachments == null || message.attachments!.isEmpty) {
return null;
}
for (final a in message.attachments!) {
if (a is ForwardedMessageAttachment) return a;
}
return null;
}
Widget _buildForwardedHeader(
BuildContext context,
ForwardedMessageAttachment forwarded,
) {
final cs = Theme.of(context).colorScheme;
final isDark = cs.brightness == Brightness.dark;
final textColor = isMe
? Colors.white
: (isDark ? cs.onSurface : const Color(0xFF1C1C1E));
final headerColor = isMe
? Colors.white.withValues(alpha: 0.7)
: (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93));
final senderName = forwarded.originalSenderName;
final displaySender = senderName ?? forwarded.originalSenderId.toString();
final origText = forwarded.originalText;
final hasOrigText = origText != null && origText.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.forward, size: 14, color: headerColor),
const SizedBox(width: 4),
Text(
displaySender,
style: TextStyle(
color: headerColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
if (hasOrigText) ...[
const SizedBox(height: 2),
Text(
origText,
style: TextStyle(color: textColor, fontSize: 14),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
);
}
Widget _buildAttachmentContent(BuildContext context) {
final attachments = message.attachments;
if (attachments == null || attachments.isEmpty) {
@@ -843,65 +776,6 @@ class MessageBubble extends StatelessWidget {
);
}
Widget _buildForwardedFileContent(
BuildContext context,
ForwardedMessageAttachment forwarded,
List<FileAttachment> files,
) {
final cs = Theme.of(context).colorScheme;
final headerColor = isMe
? Colors.white.withValues(alpha: 0.7)
: cs.onSurfaceVariant;
final displaySender =
forwarded.originalSenderName ?? forwarded.originalSenderId.toString();
final senderAvatar = forwarded.originalSenderAvatar;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.forward, size: 14, color: headerColor),
const SizedBox(width: 4),
if (senderAvatar != null && senderAvatar.isNotEmpty)
CircleAvatar(
radius: 10,
backgroundImage: CachedNetworkImageProvider(senderAvatar),
backgroundColor: cs.primaryContainer,
)
else
CircleAvatar(
radius: 10,
backgroundColor: cs.primaryContainer,
child: Text(
displaySender.isNotEmpty
? displaySender[0].toUpperCase()
: '?',
style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer),
),
),
const SizedBox(width: 6),
Text(
displaySender,
style: TextStyle(
color: headerColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(height: 4),
...files.map((file) => _buildFileAttachment(context, file)),
],
);
}
Widget _buildForwardedGenericContent(
BuildContext context,
ForwardedMessageAttachment forwarded,
@@ -1001,7 +875,7 @@ class MessageBubble extends StatelessWidget {
height: constrainedHeight,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, __, ___) => _buildPhotoPlaceholder(
errorWidget: (_, _, _) => _buildPhotoPlaceholder(
ctx,
constrainedWidth,
constrainedHeight,
@@ -1106,7 +980,7 @@ class MessageBubble extends StatelessWidget {
width: double.infinity,
height: double.infinity,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, __, ___) =>
errorWidget: (_, _, _) =>
_buildPhotoPlaceholder(ctx, 100, 100),
)
else
@@ -1141,7 +1015,7 @@ class MessageBubble extends StatelessWidget {
width: double.infinity,
height: double.infinity,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, __, ___) =>
errorWidget: (_, _, _) =>
_buildPhotoPlaceholder(ctx, 100, 100),
)
else
@@ -1209,12 +1083,6 @@ class MessageBubble extends StatelessWidget {
BuildContext ctx,
MessageAttachment attachment,
) {
final cs = Theme.of(ctx).colorScheme;
final isDark = cs.brightness == Brightness.dark;
final textColor = isMe
? Colors.white
: (isDark ? cs.onSurface : const Color(0xFF1C1C1E));
switch (attachment.type) {
case AttachmentType.video:
return _buildVideoAttachment(ctx, attachment);
@@ -1371,7 +1239,7 @@ class MessageBubble extends StatelessWidget {
height: 150,
fit: BoxFit.contain,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, __, ___) =>
errorWidget: (_, _, _) =>
_buildPhotoPlaceholder(ctx, 150, 150),
)
else
@@ -1427,7 +1295,7 @@ class MessageBubble extends StatelessWidget {
imageUrl: photoUrl,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, __, ___) => Icon(
errorWidget: (_, _, _) => Icon(
Symbols.person,
color: isMe ? Colors.white : cs.primary,
size: 24,
@@ -1573,7 +1441,7 @@ class MessageBubble extends StatelessWidget {
imageUrl: photoUrl,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, __, ___) => Icon(
errorWidget: (_, _, _) => Icon(
Symbols.person,
color: isMe ? Colors.white : cs.primary,
size: 24,
@@ -1680,7 +1548,6 @@ class MessageBubble extends StatelessWidget {
Widget _buildMeta(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final isDark = cs.brightness == Brightness.dark;
final timeColor = isMe ? Colors.white70 : cs.onSurfaceVariant;
return Padding(
@@ -1700,8 +1567,6 @@ class MessageBubble extends StatelessWidget {
}
Widget _buildCompactTime(BuildContext ctx) {
final cs = Theme.of(ctx).colorScheme;
final isDark = cs.brightness == Brightness.dark;
final bgColor = isMe
? Colors.black.withValues(alpha: 0.4)
: Colors.black.withValues(alpha: 0.5);
@@ -1761,12 +1626,6 @@ class MessageBubble extends StatelessWidget {
final minute = dt.minute.toString().padLeft(2, '0');
return '$hour:$minute';
}
String _formatDuration(int seconds) {
final min = seconds ~/ 60;
final sec = seconds % 60;
return '$min:${sec.toString().padLeft(2, '0')}';
}
}
class _VoiceMessageBubble extends StatefulWidget {
@@ -1866,6 +1725,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
return Icon(icon, size: 14, color: color);
}
@override
Widget build(BuildContext context) {
final isDark = widget.cs.brightness == Brightness.dark;
final waveInactiveColor = widget.isMe
@@ -2042,16 +1902,6 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
);
}
Widget _buildProgressBar(Color inactive, Color active) {
return Container(
height: 4,
decoration: BoxDecoration(
color: inactive,
borderRadius: BorderRadius.circular(2),
),
);
}
void _togglePlay() {
setState(() {
_isPlaying = !_isPlaying;
+18
View File
@@ -4,11 +4,13 @@ import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:komet/l10n/app_localizations.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'backend/api.dart';
import 'backend/modules/account.dart';
import 'backend/modules/contacts.dart';
import 'backend/modules/messages.dart';
import 'core/push/push_service.dart';
import 'core/storage/app_database.dart';
import 'core/storage/token_storage.dart';
import 'core/utils/haptics.dart';
@@ -43,6 +45,12 @@ void main() async {
await ContactsModule.primeCacheFromDb(activeAccountId);
}
await api.connect();
final packageInfo = await PackageInfo.fromPlatform();
if (packageInfo.packageName == 'ru.oneme.app') {
await PushService.instance.init(api: api, account: accountModule);
}
final initialLocale = await _loadInitialLocale();
await Haptics.load();
@@ -82,6 +90,7 @@ class KometAppState extends State<KometApp> {
late Locale _locale;
bool _isLoggingOut = false;
StreamSubscription<SessionExpiredException>? _sessionExpiredSub;
StreamSubscription<LoginStatus>? _loginStatusSub;
late final ValueNotifier<bool> fpsOverlayEnabled = ValueNotifier(
widget.initialFpsOverlay,
);
@@ -105,10 +114,18 @@ class KometAppState extends State<KometApp> {
} catch (_) {}
});
_loginStatusSub = accountModule.loginStatusStream.listen((status) {
if (status == LoginStatus.success) {
PushService.instance.onLoginSuccess();
}
});
_sessionExpiredSub = api.sessionExpiredStream.listen((SessionExpiredException e) async {
if (_isLoggingOut) return;
_isLoggingOut = true;
await PushService.instance.unregister();
final accountId = await TokenStorage.getActiveAccountId();
if (accountId != null) {
await accountModule.removeAccount(accountId);
@@ -133,6 +150,7 @@ class KometAppState extends State<KometApp> {
@override
void dispose() {
_sessionExpiredSub?.cancel();
_loginStatusSub?.cancel();
_profileUpdateController.close();
fpsOverlayEnabled.dispose();
super.dispose();