feat: вродь диплинки и /start

This commit is contained in:
Jganenokk
2026-07-30 21:23:20 +07:00
parent f6de41f11d
commit 25f13f0492
15 changed files with 1352 additions and 156 deletions
+11 -3
View File
@@ -412,11 +412,19 @@ class CachedMessage {
this.editHistory,
});
bool get isBotStartMarker {
final control = attachments?.whereType<ControlAttachment>().firstOrNull;
return control != null && control.isBotStart;
ControlAttachment? get controlAttachment =>
attachments?.whereType<ControlAttachment>().firstOrNull;
String? get botStartPayload {
final control = controlAttachment;
if (control == null || !control.isBotStart) return null;
final payload = (control.startPayload ?? text)?.trim();
return (payload == null || payload.isEmpty) ? null : payload;
}
bool get isSilentBotStart =>
(controlAttachment?.isBotStart ?? false) && botStartPayload == null;
CachedMessage copyWith({
String? status,
bool? deleted,
+4 -1
View File
@@ -7,6 +7,7 @@ import '../../frontend/debug/log_export.dart';
import '../../frontend/widgets/max_link_handler.dart';
import '../../main.dart';
import 'desktop_url_scheme.dart';
import 'max_link.dart';
class DeepLinkService {
DeepLinkService._();
@@ -73,7 +74,9 @@ class DeepLinkService {
if (!_ready || context == null) return;
final pending = _pending;
if (pending == null || api.state != SessionState.online) return;
if (pending == null) return;
final needsConnection = MaxLink.parse(pending)?.needsConnection ?? true;
if (needsConnection && api.state != SessionState.online) return;
_pending = null;
tryHandleMaxLink(context, pending);
}
+257 -48
View File
@@ -1,26 +1,18 @@
enum MaxLinkKind { call, invite, user, content, public, auth, stickerSet }
enum MaxContentKind { public, invite, user, content }
class MaxLink {
final MaxLinkKind kind;
final String url;
final String baseUrl;
final String? startPayload;
sealed class MaxLink {
const MaxLink();
const MaxLink(this.kind, this.url, {String? baseUrl, this.startPayload})
: baseUrl = baseUrl ?? url;
bool get needsConnection => false;
static final RegExp _host = RegExp(
r'^https?://(?:www\.)?max\.ru/(.+)$',
static final RegExp _schemeless = RegExp(
r'^(?:www\.)?max\.ru(?:[/?#]|$)',
caseSensitive: false,
);
static final RegExp _segment = RegExp(r'^[A-Za-z0-9_]+$');
static const Set<String> _reserved = {
'join',
'joincall',
'u',
'c',
'login',
'ps',
'tos',
@@ -32,49 +24,266 @@ class MaxLink {
static bool isMaxLink(String url) => parse(url) != null;
static MaxLink? parse(String input) {
final url = input.trim();
final match = _host.firstMatch(url);
if (match == null) return null;
final uri = _canonical(input);
if (uri == null) return null;
final rest = match.group(1)!;
final path = rest.split('?').first.split('#').first;
final segments = path
.split('/')
final segments = uri.pathSegments
.where((s) => s.isNotEmpty)
.toList(growable: false);
if (segments.isEmpty) return null;
final params = uri.queryParameters;
final url = uri.replace(scheme: 'https', host: 'max.ru').toString();
switch (segments.first.toLowerCase()) {
if (segments.isEmpty) return _parseQueryOnly(params);
if (segments.first.startsWith(':')) {
return _parseRoute(segments, params, url);
}
return _parseContent(segments, params, url);
}
static Uri? _canonical(String input) {
var value = input.trim();
if (value.isEmpty) return null;
if (!value.contains('://')) {
if (!_schemeless.hasMatch(value)) return null;
value = 'https://$value';
}
final uri = Uri.tryParse(value);
if (uri == null) return null;
final scheme = uri.scheme.toLowerCase();
if (scheme != 'https' && scheme != 'http' && scheme != 'max') return null;
final host = uri.host.toLowerCase();
if (host != 'max.ru' && host != 'www.max.ru') return null;
return uri;
}
static MaxLink _parseQueryOnly(Map<String, String> params) {
final userId = _idParam(params, 'uid');
if (userId != null) return MaxContactIdLink(userId);
final chatId = _idParam(params, 'cid');
if (chatId != null) return MaxChatIdLink(chatId);
return const MaxRootLink();
}
static MaxLink _parseRoute(
List<String> segments,
Map<String, String> params,
String url,
) {
final route = segments.join('/').toLowerCase();
switch (route) {
case ':auth':
return segments.length >= 2 ? MaxLink(MaxLinkKind.auth, url) : null;
case 'joincall':
return segments.length >= 2 ? MaxLink(MaxLinkKind.call, url) : null;
case 'join':
return segments.length >= 2 ? MaxLink(MaxLinkKind.invite, url) : null;
case 'u':
return segments.length >= 2 ? MaxLink(MaxLinkKind.user, url) : null;
case 'c':
return segments.length >= 3 ? MaxLink(MaxLinkKind.content, url) : null;
case 'stickerset':
return segments.length >= 2
? MaxLink(MaxLinkKind.stickerSet, url)
: null;
return MaxAuthLink(url);
case ':current':
return const MaxCurrentLink();
case ':share-self-out':
return const MaxShareSelfLink();
case ':share':
return MaxShareTextLink(params['text']?.trim() ?? '');
case ':folder':
final id = params['id']?.trim();
if (id != null && id.isNotEmpty) return MaxFolderLink(id);
}
if (segments.length > 1 && segments.first.toLowerCase() == ':auth') {
return MaxAuthLink(url);
}
return MaxRouteLink(route, params);
}
static MaxLink? _parseContent(
List<String> segments,
Map<String, String> params,
String url,
) {
final first = segments.first;
final lower = first.toLowerCase();
if (segments.length == 1) {
final name = _publicName(first);
if (name == null) return null;
final startApp = params['startapp'] ?? params['startApp'];
if (startApp != null && startApp.isNotEmpty) {
return MaxWebAppLink('https://max.ru/$name', startApp.split('&').first);
}
return MaxContentLink(
kind: MaxContentKind.public,
url: url,
baseUrl: 'https://max.ru/$name',
startPayload: _startPayload(params),
);
}
if (_reserved.contains(segments.first.toLowerCase())) return null;
if (!_segment.hasMatch(segments.first)) return null;
return MaxLink(
MaxLinkKind.public,
url,
baseUrl: 'https://max.ru/${segments.join('/')}',
startPayload: _startPayload(rest),
);
if (segments.length == 2) {
switch (lower) {
case 'stickerset':
return MaxStickerSetLink('$lower/${segments[1]}');
case 'joincall':
return MaxCallLink(url);
case 'join':
return MaxContentLink(
kind: MaxContentKind.invite,
url: url,
baseUrl: url,
);
case 'u':
return MaxContentLink(
kind: MaxContentKind.user,
url: url,
baseUrl: url,
);
}
final messageId = int.tryParse(segments[1]);
final name = _publicName(first);
if (messageId == null || name == null) return null;
return MaxContentLink(
kind: MaxContentKind.public,
url: url,
baseUrl: 'https://max.ru/$name',
messageId: messageId,
);
}
if (lower == 'c' && segments.length == 3) {
final chatId = int.tryParse(segments[1]);
final messageId = int.tryParse(segments[2]);
if (chatId == null || messageId == null) return null;
return MaxContentLink(
kind: MaxContentKind.content,
url: url,
baseUrl: 'https://max.ru/c/$chatId',
messageId: messageId,
);
}
if (lower == 'join') {
return MaxContentLink(
kind: MaxContentKind.invite,
url: url,
baseUrl: url,
);
}
return null;
}
static String? _startPayload(String rest) {
final parts = rest.split('#').first.split('?');
if (parts.length < 2) return null;
final value = Uri.splitQueryString(parts[1])['start']?.trim();
static String? _publicName(String segment) {
final name = segment.startsWith('@') ? segment.substring(1) : segment;
if (name.isEmpty) return null;
if (_reserved.contains(name.toLowerCase())) return null;
if (!_segment.hasMatch(name)) return null;
return name;
}
static String? _startPayload(Map<String, String> params) {
final value = params['start']?.trim();
return (value == null || value.isEmpty) ? null : value;
}
static int? _idParam(Map<String, String> params, String key) {
final raw = params[key]?.trim();
if (raw == null || raw.isEmpty) return null;
final value = int.tryParse(raw);
return (value == null || value <= 0) ? null : value;
}
}
class MaxRootLink extends MaxLink {
const MaxRootLink();
}
class MaxCurrentLink extends MaxLink {
const MaxCurrentLink();
}
class MaxShareSelfLink extends MaxLink {
const MaxShareSelfLink();
}
class MaxAuthLink extends MaxLink {
final String url;
const MaxAuthLink(this.url);
@override
bool get needsConnection => true;
}
class MaxCallLink extends MaxLink {
final String url;
const MaxCallLink(this.url);
@override
bool get needsConnection => true;
}
class MaxStickerSetLink extends MaxLink {
final String path;
const MaxStickerSetLink(this.path);
@override
bool get needsConnection => true;
}
class MaxShareTextLink extends MaxLink {
final String text;
const MaxShareTextLink(this.text);
}
class MaxFolderLink extends MaxLink {
final String folderId;
const MaxFolderLink(this.folderId);
}
class MaxRouteLink extends MaxLink {
final String route;
final Map<String, String> params;
const MaxRouteLink(this.route, this.params);
}
class MaxContactIdLink extends MaxLink {
final int userId;
const MaxContactIdLink(this.userId);
@override
bool get needsConnection => true;
}
class MaxChatIdLink extends MaxLink {
final int chatId;
final int? messageId;
const MaxChatIdLink(this.chatId, {this.messageId});
}
class MaxWebAppLink extends MaxLink {
final String url;
final String startApp;
const MaxWebAppLink(this.url, this.startApp);
@override
bool get needsConnection => true;
}
class MaxContentLink extends MaxLink {
final MaxContentKind kind;
final String url;
final String baseUrl;
final String? startPayload;
final int? messageId;
const MaxContentLink({
required this.kind,
required this.url,
required this.baseUrl,
this.startPayload,
this.messageId,
});
@override
bool get needsConnection => true;
}
@@ -152,6 +152,36 @@ class ChatListScreen extends StatefulWidget {
this.archiveMode = false,
});
static _ChatListScreenState? _root;
static bool selectTab(int index) {
final root = _root;
if (root == null || !root.mounted) return false;
root._onNavTabSelected(index);
return true;
}
static bool selectFolder(String folderId) {
final root = _root;
if (root == null || !root.mounted) return false;
root._onNavTabSelected(0);
return root._selectFolder(folderId);
}
static bool openSavedMessages() {
final root = _root;
if (root == null || !root.mounted) return false;
root._openSavedMessages();
return true;
}
static bool openSearch() {
final root = _root;
if (root == null || !root.mounted) return false;
root._openSearch();
return true;
}
@override
State<ChatListScreen> createState() => _ChatListScreenState();
}
@@ -553,6 +583,7 @@ class _ChatListScreenState extends State<ChatListScreen>
@override
void initState() {
super.initState();
if (!widget.forwardMode && !widget.archiveMode) ChatListScreen._root = this;
_fabController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 350),
@@ -1248,6 +1279,7 @@ class _ChatListScreenState extends State<ChatListScreen>
@override
void dispose() {
if (ChatListScreen._root == this) ChatListScreen._root = null;
appRouteObserver.unsubscribe(this);
_settleTimer?.cancel();
chats.chatsChanged.removeListener(_onChatsChanged);
@@ -1575,12 +1607,7 @@ class _ChatListScreenState extends State<ChatListScreen>
padding: const EdgeInsets.fromLTRB(20, 3, 20, 8),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.forwardMode
? null
: () => pushSwipeable(
context,
(_) => const SearchScreen(),
),
onTap: widget.forwardMode ? null : _openSearch,
child: GlossyPill(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(50),
@@ -2543,28 +2570,31 @@ class _ChatListScreenState extends State<ChatListScreen>
return f.title;
}
bool _selectFolder(String folderId) {
final target = _folders.indexWhere((f) => f.id == folderId);
if (target < 0) return false;
setState(() => _selectedFolderId = folderId);
if (_folderPageController.hasClients) {
final cur = _folderPageController.page?.round() ?? 0;
if (cur == target) return true;
if ((target - cur).abs() > 1) {
final neighbor = target > cur ? target - 1 : target + 1;
_folderPageController.jumpToPage(neighbor);
}
_folderPageController.animateToPage(
target,
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
);
}
return true;
}
Widget _buildFolderChip(String title, {required String folderId}) {
final cs = Theme.of(context).colorScheme;
final isSelected = _selectedFolderId == folderId;
return GestureDetector(
onTap: () {
final target = _folders.indexWhere((f) => f.id == folderId);
if (target < 0) return;
setState(() => _selectedFolderId = folderId);
if (_folderPageController.hasClients) {
final cur = _folderPageController.page?.round() ?? 0;
if (cur == target) return;
if ((target - cur).abs() > 1) {
final neighbor = target > cur ? target - 1 : target + 1;
_folderPageController.jumpToPage(neighbor);
}
_folderPageController.animateToPage(
target,
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
);
}
},
onTap: () => _selectFolder(folderId),
child: GlossyPill(
color: isSelected ? cs.primaryContainer : cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(50),
@@ -3190,6 +3220,9 @@ class _ChatListScreenState extends State<ChatListScreen>
);
}
void _openSearch() =>
unawaited(pushSwipeable(context, (_) => const SearchScreen()));
void _openSavedMessages() {
CachedChat? self;
for (final c in _chats) {
+6 -1
View File
@@ -242,6 +242,7 @@ class ChatScreen extends StatefulWidget {
final String? commentPostId;
final CachedMessage? postMessage;
final String? botStartPayload;
final String? initialText;
const ChatScreen({
super.key,
@@ -259,6 +260,7 @@ class ChatScreen extends StatefulWidget {
this.commentPostId,
this.postMessage,
this.botStartPayload,
this.initialText,
});
static final List<_ChatScreenState> _open = [];
@@ -2058,7 +2060,10 @@ class _ChatScreenState extends State<ChatScreen>
if (_myId == 0 || _commentsMode || _messageController.text.isNotEmpty) {
return;
}
final draft = DraftStore.instance.get(_myId, widget.chatId);
final shared = widget.initialText?.trim();
final draft = (shared != null && shared.isNotEmpty)
? shared
: DraftStore.instance.get(_myId, widget.chatId);
if (draft == null || draft.isEmpty) return;
_messageController.text = draft;
_messageController.selection = TextSelection.collapsed(
@@ -96,6 +96,12 @@ class _ControlBubbleState extends State<ControlBubble> {
sender,
const _ControlSegment(' закрепил(а) сообщение'),
], senderId);
case ControlAttachment.botStartedEvent:
final payload = widget.message.botStartPayload;
return _ControlText([
const _ControlSegment('Бот запущен'),
if (payload != null) _ControlSegment(': $payload'),
], null);
default:
return _ControlText([
_ControlSegment(control.title ?? ''),
+113 -40
View File
@@ -1,9 +1,11 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:share_plus/share_plus.dart';
import '../../backend/modules/chats.dart';
import '../../backend/modules/links.dart';
import '../../core/cache/info_cache.dart';
import '../../core/links/max_link.dart';
import '../../core/storage/app_database.dart';
import '../../main.dart';
@@ -12,7 +14,8 @@ import '../screens/contacts/open_contact_profile.dart';
import 'call_link_handler.dart';
import 'confirm_dialog.dart';
import 'custom_notification.dart';
import 'sticker_pack_sheet.dart';
import 'max_link_nav.dart';
import 'max_route_handler.dart';
import 'swipe_route.dart';
import 'web_qr_login.dart';
@@ -20,20 +23,47 @@ Future<bool> tryHandleMaxLink(BuildContext context, String url) async {
final link = MaxLink.parse(url);
if (link == null) return false;
if (link.kind == MaxLinkKind.call) {
return tryHandleCallLink(context, url);
switch (link) {
case MaxRootLink():
popToAppRoot(context);
return true;
case MaxCurrentLink():
return true;
case MaxAuthLink(:final url):
await confirmAndAuthorizeWebQrLogin(context, url);
return true;
case MaxCallLink(:final url):
return tryHandleCallLink(context, url);
case MaxStickerSetLink(:final path):
return openStickerSetByPath(context, path);
case MaxShareSelfLink():
return _shareOwnLink(context);
case MaxShareTextLink(:final text):
return shareTextToChat(context, text);
case MaxFolderLink(:final folderId):
return openFolderChatList(context, folderId);
case MaxRouteLink(:final route, :final params):
return openMaxRoute(context, route, params);
case MaxContactIdLink(:final userId):
return openContactById(context, userId);
case MaxChatIdLink(:final chatId, :final messageId):
return openChatById(context, chatId, messageId: messageId);
case MaxWebAppLink():
return _openWebAppLink(context, link);
case MaxContentLink():
return _openContentLink(context, link);
}
}
if (link.kind == MaxLinkKind.auth) {
await confirmAndAuthorizeWebQrLogin(context, link.url);
return true;
}
Future<ResolvedLink?> _resolve(String url, String baseUrl) async {
final resolved = await LinkModule.resolve(api, url);
if (baseUrl == url) return resolved;
if (resolved is ResolvedChat || resolved is ResolvedUser) return resolved;
return LinkModule.resolve(api, baseUrl);
}
if (link.kind == MaxLinkKind.stickerSet) {
return _openStickerSet(context, link.url);
}
final resolved = await _resolve(link);
Future<bool> _openContentLink(BuildContext context, MaxContentLink link) async {
final resolved = await _resolve(link.url, link.baseUrl);
if (!context.mounted) return true;
switch (resolved) {
@@ -51,36 +81,62 @@ Future<bool> tryHandleMaxLink(BuildContext context, String url) async {
}
}
Future<ResolvedLink?> _resolve(MaxLink link) async {
final resolved = await LinkModule.resolve(api, link.url);
if (link.startPayload == null || link.baseUrl == link.url) return resolved;
if (resolved is ResolvedChat || resolved is ResolvedUser) return resolved;
return LinkModule.resolve(api, link.baseUrl);
}
Future<bool> _openStickerSet(BuildContext context, String url) async {
final path = url
.replaceFirst(
RegExp(r'^https?://(?:www\.)?max\.ru/', caseSensitive: false),
'',
)
.split('?')
.first
.split('#')
.first;
final set = await stickersModule.resolveSetByLink(path);
Future<bool> _openWebAppLink(BuildContext context, MaxWebAppLink link) async {
final resolved = await _resolve(link.url, link.url);
if (!context.mounted) return true;
if (set == null) {
showCustomNotification(context, 'Стикерпак недоступен');
final botId = await _botIdOf(resolved);
if (!context.mounted) return true;
if (botId == null) {
final message = resolved is ResolvedLinkError
? resolved.message
: 'Не удалось открыть приложение';
showCustomNotification(context, message);
return true;
}
await showStickerPackSheet(context, knownSetId: set.id);
return openWebAppForBot(context, botId, startParam: link.startApp);
}
Future<int?> _botIdOf(ResolvedLink? resolved) async {
switch (resolved) {
case ResolvedUser(:final contact):
final id = contact['id'];
return id is int ? id : null;
case ResolvedChat(:final chat):
final chatId = chat['id'];
if (chatId is! int) return null;
if ((chat['type'] as String?) != 'DIALOG') return null;
final myId = await currentAccountId();
return myId == 0 ? null : chatId ^ myId;
default:
return null;
}
}
Future<bool> _shareOwnLink(BuildContext context) async {
final myId = await currentAccountId();
if (myId == 0) return false;
final info = await ContactInfoFetch.get(myId);
if (!context.mounted) return true;
final link = (info?.raw['link'] as String?)?.trim();
if (link == null || link.isEmpty) {
showCustomNotification(context, 'У профиля нет публичной ссылки');
return true;
}
try {
await Share.share(link);
} catch (_) {
if (context.mounted) {
showCustomNotification(context, 'Не удалось поделиться ссылкой');
}
}
return true;
}
Future<void> _openContact(
BuildContext context,
MaxLink link,
MaxContentLink link,
Map<dynamic, dynamic> contact,
) async {
final id = contact['id'];
@@ -112,8 +168,7 @@ Future<bool> _startBotDialog(
Map<dynamic, dynamic> contact,
String startPayload,
) async {
final profile = await AppDatabase.loadActiveProfile();
final myId = profile?.id ?? 0;
final myId = await currentAccountId();
if (myId == 0) return false;
final chatId =
@@ -155,7 +210,7 @@ void _openChatAndStartBot(
Future<void> _openResolvedChat(
BuildContext context,
MaxLink link,
MaxContentLink link,
ResolvedChat resolved,
) async {
final chat = resolved.chat;
@@ -170,14 +225,15 @@ Future<void> _openResolvedChat(
final icon = (chat['baseIconUrl'] as String?) ?? '';
final access = chat['access'];
final profile = await AppDatabase.loadActiveProfile();
final myId = profile?.id ?? 0;
final myId = await currentAccountId();
var isMember = myId != 0 && await AppDatabase.isChatInList(myId, id);
await chats.cacheServerChat(chat, myId, inList: isMember);
if (!context.mounted) return;
if (link.kind == MaxLinkKind.invite && access == 'PRIVATE' && !isMember) {
if (link.kind == MaxContentKind.invite &&
access == 'PRIVATE' &&
!isMember) {
final label = title.isEmpty ? 'этот чат' : '«$title»';
final confirmed = await showConfirmDialog(
context,
@@ -210,6 +266,7 @@ Future<void> _openResolvedChat(
return;
}
final target = _messageTarget(link, resolved.message);
pushSwipeable(
context,
(_) => ChatScreen(
@@ -218,10 +275,26 @@ Future<void> _openResolvedChat(
imageUrl: icon,
chatType: type,
channelSubscribed: type == 'CHANNEL' ? isMember : null,
initialMessageId: target?.id,
initialMessageTime: target?.time,
),
);
}
({String id, int? time})? _messageTarget(
MaxContentLink link,
Map<dynamic, dynamic>? message,
) {
final serverId = message?['id']?.toString();
final time = message?['time'];
if (serverId != null && serverId.isNotEmpty) {
return (id: serverId, time: time is int ? time : null);
}
final messageId = link.messageId;
if (messageId == null) return null;
return (id: messageId.toString(), time: null);
}
String _contactName(Map<dynamic, dynamic> contact) {
final names = contact['names'];
if (names is List && names.isNotEmpty && names.first is Map) {
+172
View File
@@ -0,0 +1,172 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../backend/modules/chats.dart';
import '../../backend/modules/messages.dart' show ContactCache;
import '../../core/cache/info_cache.dart';
import '../../core/storage/app_database.dart';
import '../../core/utils/webview_support.dart';
import '../../main.dart';
import '../screens/chats/chat_list_screen.dart';
import '../screens/chats/chat_screen.dart';
import '../screens/contacts/open_contact_profile.dart';
import '../screens/webapp/web_app_screen.dart';
import 'custom_notification.dart';
import 'sticker_pack_sheet.dart';
import 'swipe_route.dart';
void popToAppRoot(BuildContext context) {
Navigator.of(context).popUntil((route) => route.isFirst);
}
Future<BuildContext?> popToAppRootAndSettle(BuildContext context) async {
popToAppRoot(context);
await WidgetsBinding.instance.endOfFrame;
return KometApp.navigatorKey.currentContext;
}
Future<int> currentAccountId() async {
final profile = await AppDatabase.loadActiveProfile();
return profile?.id ?? 0;
}
Future<CachedChat?> resolveChat(int myId, int chatId) async {
var rows = await chats.getChat(myId, chatId);
if (rows.isEmpty) {
await chats.ensureChatCached(api, myId, chatId);
rows = await chats.getChat(myId, chatId);
}
return rows.isEmpty ? null : rows.first;
}
Future<bool> openChatById(
BuildContext context,
int chatId, {
int? messageId,
int? messageTime,
String? initialText,
}) async {
final myId = await currentAccountId();
if (myId == 0) return false;
final chat = await resolveChat(myId, chatId);
if (!context.mounted) return false;
if (chat == null) {
showCustomNotification(context, 'Чат не найден');
return true;
}
final title = chat.title?.trim();
final name = (title != null && title.isNotEmpty)
? title
: (ContactCache.get(chatId ^ myId) ?? 'Чат');
await pushSwipeable(
context,
(_) => ChatScreen(
chatId: chatId,
name: name,
imageUrl: chat.iconUrl ?? '',
chatType: chat.type,
initialMessageId: messageId?.toString(),
initialMessageTime: messageTime,
initialText: initialText,
),
);
return true;
}
Future<bool> openContactById(BuildContext context, int userId) async {
final info = await ContactInfoFetch.get(userId);
if (!context.mounted) return false;
await openContactDialogProfile(
context,
contactId: userId,
name: ContactCache.get(userId) ?? info?.displayName ?? 'Профиль',
avatarUrl: info?.avatarUrl,
);
return true;
}
Future<bool> openStickerSetByPath(BuildContext context, String path) async {
final set = await stickersModule.resolveSetByLink(path);
if (!context.mounted) return true;
if (set == null) {
showCustomNotification(context, 'Стикерпак недоступен');
return true;
}
await showStickerPackSheet(context, knownSetId: set.id);
return true;
}
Future<bool> openStickerSetById(BuildContext context, int setId) async {
await showStickerPackSheet(context, knownSetId: setId);
return true;
}
Future<bool> openWebAppForBot(
BuildContext context,
int botId, {
String? startParam,
int? chatId,
}) async {
if (!webViewSupported) {
showCustomNotification(context, 'На вашей платформе это недоступно');
return true;
}
final myId = await currentAccountId();
if (!context.mounted) return false;
final dialogId = chatId ?? (myId == 0 ? null : myId ^ botId);
final title = ContactCache.get(botId) ?? 'Приложение';
await pushSwipeable(
context,
(_) => WebAppScreen(
title: title,
loader: () => webAppModule.fetchLaunch(
botId,
startParam: startParam,
chatId: dialogId,
),
),
);
return true;
}
Future<bool> shareTextToChat(BuildContext context, String text) async {
if (text.isEmpty) {
showCustomNotification(context, 'Нечего отправлять');
return true;
}
final target = await openForwardScreen(context: context);
if (target == null || !context.mounted) return true;
await pushSwipeable(
context,
(_) => ChatScreen(
chatId: target.chatId,
name: target.name,
imageUrl: target.imageUrl,
chatType: target.chatType,
initialText: text,
),
);
return true;
}
Future<bool> openFolderChatList(BuildContext context, String folderId) async {
final root = await popToAppRootAndSettle(context);
if (ChatListScreen.selectFolder(folderId)) return true;
if (root != null) showCustomNotification(root, 'Папка не найдена');
return true;
}
Future<bool> openRootTab(BuildContext context, int index) async {
final root = await popToAppRootAndSettle(context);
if (ChatListScreen.selectTab(index)) return true;
if (root != null) notifyNeedsAccount(root);
return true;
}
void notifyNeedsAccount(BuildContext context) =>
showCustomNotification(context, 'Сначала войдите в аккаунт');
+205
View File
@@ -0,0 +1,205 @@
import 'package:flutter/material.dart';
import '../../core/utils/link_opener.dart';
import '../screens/chats/chat_info_screen.dart';
import '../screens/chats/chat_list_screen.dart';
import '../screens/chats/scheduled_messages_screen.dart';
import '../screens/profile/appearance_screen.dart';
import '../screens/profile/debug_menu_screen.dart';
import '../screens/profile/devices_screen.dart';
import '../screens/profile/edit_profile_screen.dart';
import '../screens/profile/info_screen.dart';
import '../screens/profile/message_actions_screen.dart';
import '../screens/profile/notifications_screen.dart';
import '../screens/profile/security_screen.dart';
import '../screens/profile/web_qr_scan_screen.dart';
import 'custom_notification.dart';
import 'max_link_nav.dart';
import 'swipe_route.dart';
Future<bool> openMaxRoute(
BuildContext context,
String route,
Map<String, String> params,
) async {
switch (route) {
case ':chat-list':
case ':settings/folder-list':
return openRootTab(context, 0);
case ':calls-history':
case ':call-list':
return openRootTab(context, 1);
case ':contact-list':
return openRootTab(context, 2);
case ':settings':
return openRootTab(context, 3);
case ':chats-search':
final root = await popToAppRootAndSettle(context);
if (ChatListScreen.openSearch()) return true;
if (root != null) notifyNeedsAccount(root);
return true;
case ':saved-messages':
final root = await popToAppRootAndSettle(context);
if (ChatListScreen.openSavedMessages()) return true;
if (root != null) notifyNeedsAccount(root);
return true;
case ':settings/folder':
final id = params['id']?.trim();
if (id == null || id.isEmpty) return _badLink(context, route);
return openFolderChatList(context, id);
case ':chats':
final id = _intOf(params['id']);
if (id == null) return _badLink(context, route);
return openChatById(context, id);
case ':profile':
case ':profile/members':
case ':profile/avatars':
final id = _intOf(params['id']);
if (id == null) return _badLink(context, route);
final type = (params['type'] ?? '').toUpperCase();
if (type == 'CHAT' || type == 'CHANNEL') {
return openChatInfoById(context, id);
}
return openContactById(context, id);
case ':profile/attaches':
final id = _intOf(params['id']);
if (id == null) return _badLink(context, route);
return openChatInfoById(context, id, initialTab: ChatInfoTab.media);
case ':profile/edit':
return _push(context, const EditProfileScreen());
case ':scheduled-messages':
final id = _intOf(params['id']);
if (id == null) return _badLink(context, route);
return openScheduledMessages(context, id);
case ':stickers/set':
final setId = _intOf(params['set_id']);
if (setId == null) return _badLink(context, route);
return openStickerSetById(context, setId);
case ':webapp:root':
final botId = _intOf(params['bot_id']);
if (botId == null) return _badLink(context, route);
return openWebAppForBot(
context,
botId,
startParam: params['entry_point'],
chatId: _intOf(params['chat_id']),
);
case ':settings/webapp':
final botId = _intOf(params['bot_id']);
if (botId == null) return _badLink(context, route);
return openWebAppForBot(context, botId);
case ':location/show':
final lat = double.tryParse(params['lat'] ?? '');
final lon = double.tryParse(params['lon'] ?? '');
if (lat == null || lon == null) return _badLink(context, route);
await openLocationOnMap(
context,
lat,
lon,
zoom: double.tryParse(params['z'] ?? ''),
);
return true;
case ':qr-scanner':
return _push(context, const WebQrScanScreen());
case ':settings/appearance':
return _push(context, const AppearanceScreen());
case ':settings/notifications':
case ':settings/notifications/chat':
case ':settings/notifications/dialog':
case ':settings/notifications/other':
return _push(context, const NotificationsScreen());
case ':settings/devices':
return _push(context, const DevicesScreen());
case ':settings/aboutapp':
return _push(context, const InfoScreen());
case ':settings/privacy':
case ':settings/privacy/pincode':
case ':settings/blacklist':
return _push(context, const SecurityScreen());
case ':settings/messages':
return _push(context, const MessageActionsScreen());
case ':settings/dev':
case ':settings/dev/logsviewer':
case ':settings/dev/memorydebugger':
case ':settings/dev/showroom':
case ':settings/dev/threadsviewer':
case ':settings/dev/integritylogsviewer':
return _push(context, const DebugMenuScreen());
case ':current':
case ':link-intercept':
case ':external_callback':
return true;
}
showCustomNotification(context, 'Ссылка не поддерживается: $route');
return true;
}
Future<bool> openChatInfoById(
BuildContext context,
int chatId, {
ChatInfoTab? initialTab,
}) async {
final myId = await currentAccountId();
if (myId == 0) return false;
final chat = await resolveChat(myId, chatId);
if (!context.mounted) return false;
final title = chat?.title?.trim();
return _push(
context,
ChatInfoScreen(
chatId: chatId,
name: (title != null && title.isNotEmpty) ? title : 'Чат',
imageUrl: chat?.iconUrl ?? '',
chatType: chat?.type ?? 'CHAT',
initialTab: initialTab,
),
);
}
Future<bool> openScheduledMessages(BuildContext context, int chatId) async {
final myId = await currentAccountId();
if (myId == 0) return false;
final chat = await resolveChat(myId, chatId);
if (!context.mounted) return false;
final title = chat?.title?.trim();
return _push(
context,
ScheduledMessagesScreen(
chatId: chatId,
accountId: myId,
chatName: (title != null && title.isNotEmpty) ? title : 'Чат',
),
);
}
Future<bool> _push(BuildContext context, Widget screen) async {
await pushSwipeable(context, (_) => screen);
return true;
}
bool _badLink(BuildContext context, String route) {
showCustomNotification(context, 'Неполная ссылка: $route');
return true;
}
int? _intOf(String? raw) {
final value = int.tryParse(raw?.trim() ?? '');
return (value == null || value <= 0) ? null : value;
}
+1 -1
View File
@@ -752,7 +752,7 @@ class MessageBubble extends StatelessWidget {
final contentType = _contentType;
if (message.isControl) {
if (message.isBotStartMarker) return const SizedBox.shrink();
if (message.isSilentBotStart) return const SizedBox.shrink();
const controlShape = BubbleShape.singleMiddle;
return Padding(
padding: EdgeInsets.only(
+4
View File
@@ -435,6 +435,7 @@ class ControlAttachment extends MessageAttachment {
final String? title;
final List<int>? userIds;
final int? userId;
final String? startPayload;
const ControlAttachment({
super.previewData,
@@ -444,6 +445,7 @@ class ControlAttachment extends MessageAttachment {
this.title,
this.userIds,
this.userId,
this.startPayload,
}) : super(type: AttachmentType.control);
bool get isBotStart => event == botStartedEvent;
@@ -463,6 +465,7 @@ class ControlAttachment extends MessageAttachment {
userId: map['userId'] is int
? map['userId'] as int
: int.tryParse(map['userId']?.toString() ?? ''),
startPayload: map['startPayload']?.toString(),
);
}
@@ -475,6 +478,7 @@ class ControlAttachment extends MessageAttachment {
'title': title,
'userIds': userIds,
'userId': userId,
'startPayload': startPayload,
};
}
+288
View File
@@ -0,0 +1,288 @@
# Диплинки MAX
Разобрано из `ru.oneme.app.apk` — MAX 26.24.0 (versionCode 6784, minSdk 26).
Точки входа: `AndroidManifest.xml``one.me.android.deeplink.LinkInterceptorActivity`,
парсер `one.me.link.interceptor.b0.a()`, хелпер `ru.ok.messages.utils.a`,
реестр внутренних маршрутов `iz4` / `nz4` (`DeepLinkRoute`), навигатор `pz4`.
## Что вообще перехватывается
Один активити (`LinkInterceptorActivity`) с двумя intent-filter, `android:autoVerify="true"`:
| Схема | Хост | Путь |
|---|---|---|
| `https` | `max.ru` | `/..*` (минимум один символ после `/`) |
| `http` | `max.ru` | `/..*` |
| `max` | `max.ru` | любой |
`max://…` внутри сразу переписывается в `https://…` (`b0.k()`), так что дальше всё едино.
Важная деталь: `/..*` требует непустой путь, поэтому ссылки вида `https://max.ru/?uid=123`
из браузера в приложение не попадают — только через схему `max://`, где путь не ограничен.
Нормализация ссылки перед разбором (`ru.ok.messages.utils.a.e()`):
- завершающий `/` отбрасывается;
- строка без схемы → дописывается `https://` (**то есть `max.ru/xxx` — валидный диплинк**);
- начинается с `:` или `max://:` → внутренний маршрут (см. ниже);
- начинается с `@` → ник, ссылка не переписывается.
Общий порядок разбора в `b0.a()`: `:auth` → (не готов к работе → `OpenApp` с отложенной ссылкой)
`:current` → внутренний маршрут → корневая ссылка → `:share-self-out` → чужой хост → контентные ссылки.
## Контентные ссылки max.ru
| Ссылка | Что открывает | Результат в коде |
|---|---|---|
| `max.ru``http://max.ru`, `https://max.ru`, `max://max.ru`, `max://max.ru/`) | просто открывает приложение | `DeepLinkData$OpenApp``OpenApp` |
| `max.ru/<username>` | чат/канал/бота по публичной ссылке; если такой чат уже локально есть — открывает его напрямую, иначе резолвит на сервере | `JoinLink``ShowChat` / `ShowContact` / `ConfirmJoin` |
| `max.ru/@<nickname>` | то же, ник ищется среди ссылок известных чатов | → чат либо `UnknownContact` |
| `max.ru/<username>?start=<payload>` | диалог с ботом + автозапуск: отправляется `botStarted` со `startPayload` | `ShowContactDialog(chatId, startPayload, externalCallback)` |
| `max.ru/<username>?startapp=<payload>` | мини-приложение бота (payload обрезается по первому `&`, query из ссылки вычищается) | `StartWebAppLink``OpenWebApp(botId, startParam)` / `ErrorWebAppNotExist` |
| `max.ru/join/<code>` | вступление по приватной инвайт-ссылке | `JoinLink``ConfirmJoin` / `ShowChat` |
| `max.ru/joincall/<code>` | экран входа в звонок по ссылке | `CallJoinLink``ShowJoinCall` |
| `max.ru/stickerset/<id>` | стикерпак; `id` берётся до первого `-` | `StickerSet``ShowStickerSet` |
| `max.ru/<username>/<messageId>` | чат на конкретном сообщении (второй сегмент должен быть числом) | `MessagestLink``ShowChat(chatId, messageId)` |
| `max.ru/c/<chatId>/<messageId>` | сообщение/пост в чате по числовым id | `MessagestLink``ShowChat` |
| `max.ru/:folder?id=<folderId>` | список чатов в папке | `FolderChatList``OpenChatListInFolder`, иначе `UnknownFolderError` |
| `max.ru/?uid=<userId>` | контакт/диалог по id пользователя (ищется локально) | `DeepLinkData(contactId)` |
| `max.ru/?cid=<chatId>` | чат по серверному id (ищется локально) | `DeepLinkData(chatId)` |
| `max.ru/:auth/<...>` | подтверждение веб-логина по QR; путь принудительно урезается до `https://max.ru/:auth` | маршрут `:auth` |
| `max.ru/:current` | остаётся на текущем экране (для внешних колбэков) | `OpenCurrent` |
| `https://max.ru/:share-self-out` | системный шэринг своей инвайт-ссылки | `OpenExternalSharingToInvite` |
| `max.ru/:share?text=<text>` | шэринг текста внутрь приложения (выбор чата) | маршрут `:share` |
| любой другой хост | открывается во внешнем браузере | `OpenBrowser` |
Ошибочные ветки: `ErrorBrokenLink`, `ErrorPrivateChat`, `ErrorPrivateChannel`,
`ErrorMessageNotFounded`, `ErrorPostNotFounded`, `ErrorWebAppNotExist`,
`ContentLevelError`, `ItsYou` (ссылка на себя), `ShowContactRemoved`.
Отдельный флаг: `?externalCallback=1` в любой ссылке — результат прокидывается обратно
как внешний колбэк (`b0.d()`), плюс есть маршрут `:external_callback`.
Параметры `mt_*` (myTracker) вычищаются из ссылки до разбора.
## Внутренние маршруты (`:route`)
Полноценная часть диплинк-системы: 137 маршрутов, объявленных как
`DeepLinkRoute(uri, constraints, requiredParams, supportRoot)`. Матчинг — по пути без
ведущего `/`, регистронезависимо. Обязательные параметры передаются как query:
`max.ru/:profile?id=123&type=CHAT`. Если хотя бы одного обязательного параметра нет —
`Error`, экран не откроется.
Помимо http(s)/`max://` эти же маршруты дергаются изнутри приложения (`pz4.b/d`) и из пушей.
Пометки в таблице:
- **только внутри приложения** — маршрут исключён из обработки внешних ссылок (`constraints` содержит `k2b.g`);
- **без авторизации** — доступен без активной сессии, иначе редирект на `:login`;
- **не может быть корневым экраном**`supportRoot = false`.
| Маршрут | Обязательные параметры | Примечания |
|---|---|---|
| `:app-update/force` | — | без авторизации |
| `:attach/viewer` | `chat_id`, `attach_id`, `msg_id` | |
| `:auth` | — | |
| `:call-active` | — | |
| `:call-admin-settings` | — | |
| `:call-admin-waiting-room` | — | |
| `:call-chat` | `chat_id` | только внутри приложения |
| `:call-contact` | — | |
| `:call-debug-menu` | — | |
| `:call-history-info` | — | |
| `:call-incoming` | `chat_id`, `call_name` | |
| `:call-join-link` | `link` | только внутри приложения |
| `:call-join-preview` | `link` | |
| `:call-list` | — | |
| `:call-opponents-list` | — | |
| `:call-pip` | — | |
| `:call-presettings` | `chat_id` | |
| `:call-rate` | `call_id`, `is_group`, `is_video` | |
| `:call-user` | `opponent_id` | только внутри приложения |
| `:calls-history` | — | |
| `:chat-list` | — | |
| `:chat/add-icon` | — | |
| `:chats` | `id`, `type` | |
| `:chats-search` | — | |
| `:chats/callshare` | — | |
| `:chats/forward` | `messages_ids` | |
| `:chats/share` | — | |
| `:comments` | `parent_chat_server_id`, `parent_message_server_id` | |
| `:complaint` | — | |
| `:contact-list` | — | |
| `:contact-list/create-contact` | — | |
| `:contact-list/share-invite` | — | |
| `:contact/add/dialog` | `contact_id` | |
| `:contacts-picker` | `request_code` | |
| `:dialogs/file-download-warning` | `chat_id`, `message_id`, `file_id`, `file_name`, `file_size` | |
| `:dialogs/share-media` | `msg_id`, `attach_id`, `local_attach_id`, `cause_ordinal` | |
| `:external_callback` | — | |
| `:inAppReview/fake` | — | |
| `:invite/friends_to_max_bottom_sheet` | — | |
| `:invite/phone` | — | |
| `:invite/qr` | — | |
| `:join` | `id`, `link` | |
| `:link-intercept` | — | |
| `:location/pick` | `chat_id`, `request_code` | |
| `:location/show` | `chat_id`, `lat`, `lon`, `z` | |
| `:login` | — | без авторизации |
| `:logout` | — | без авторизации |
| `:media-editor` | — | |
| `:media-editor/crop` | `image_uri`, `file_path`, `mode` | |
| `:media-picker/select/photo` | — | без авторизации, не корневой экран |
| `:neuro-avatars` | `id` | |
| `:photo-editor` | — | |
| `:polls/create` | `chat_id`, `request_code` | |
| `:polls/result` | `chat_id`, `message_id`, `poll_id` | |
| `:polls/result/voters` | `chat_id`, `message_id`, `poll_id`, `answer_id` | |
| `:profile` | `id`, `type` | |
| `:profile/add-admins` | `chat_id` | |
| `:profile/add-members` | `chat_id`, `is_chat` | |
| `:profile/attaches` | `id` | |
| `:profile/avatars` | `id`, `type` | |
| `:profile/change-owner` | `chat_id` | |
| `:profile/comments-black-list` | `id` | |
| `:profile/edit` | `id`, `type` | |
| `:profile/edit/admin_permission` | `chat_id`, `contact_id`, `permissions_type` | |
| `:profile/edit/link` | `id`, `type`, `flow` | |
| `:profile/edit/reactions` | `id` | |
| `:profile/invite` | `id` | |
| `:profile/join-requests` | `id` | |
| `:profile/member_permissions` | `id` | |
| `:profile/members` | `id`, `type` | |
| `:qr-scanner` | — | |
| `:saved-messages` | — | |
| `:scheduled-messages` | `id` | |
| `:settings` | — | |
| `:settings/aboutapp` | — | |
| `:settings/appearance` | — | |
| `:settings/battery` | — | |
| `:settings/blacklist` | — | |
| `:settings/caching` | — | |
| `:settings/dev` | — | без авторизации, не корневой экран |
| `:settings/dev/integritylogsviewer` | — | без авторизации, не корневой экран |
| `:settings/dev/logsviewer` | — | без авторизации, не корневой экран |
| `:settings/dev/memorydebugger` | — | без авторизации |
| `:settings/dev/showroom` | — | без авторизации |
| `:settings/dev/threadsviewer` | — | без авторизации |
| `:settings/devices` | — | |
| `:settings/folder` | `id` | |
| `:settings/folder-list` | — | |
| `:settings/folder/by-chat` | `ids` | |
| `:settings/folder/create` | — | |
| `:settings/folder/edit` | — | |
| `:settings/folder/members-picker` | — | |
| `:settings/folder/settings` | — | |
| `:settings/locale` | — | |
| `:settings/magic-room` | — | без авторизации |
| `:settings/media` | — | |
| `:settings/media/autoload/video` | — | |
| `:settings/media/autosave` | `type` | |
| `:settings/messages` | — | |
| `:settings/notifications` | — | |
| `:settings/notifications/chat` | — | |
| `:settings/notifications/dialog` | — | |
| `:settings/notifications/other` | — | |
| `:settings/privacy` | — | |
| `:settings/privacy/creation-twofa` | `track_id`, `src` | |
| `:settings/privacy/onboarding` | — | |
| `:settings/privacy/onboarding-twofa` | `state` | |
| `:settings/privacy/pincode` | `mode` | |
| `:settings/privacy/profile-deletion` | — | |
| `:settings/ringtone` | — | |
| `:settings/server-host` | — | без авторизации |
| `:settings/server-port` | — | без авторизации |
| `:settings/webapp` | `bot_id` | |
| `:settings/webapps` | — | |
| `:share` | `text` | |
| `:start-conversation` | — | |
| `:start-conversation/add-subscribers` | `id` | |
| `:start-conversation/channel` | — | |
| `:start-conversation/chat` | — | |
| `:stickers/favorite` | — | |
| `:stickers/preview` | `sticker_id` | |
| `:stickers/recent` | — | |
| `:stickers/search` | — | |
| `:stickers/set` | `set_id` | |
| `:stickers/settings` | — | |
| `:stickers/showcase` | — | |
| `:stories/edit-privacy` | `story_id`, `settings` | |
| `:stories/publish` | `path` | |
| `:stories/publish/picker` | `title` | |
| `:stories/viewer` | `owner_id`, `owner_type`, `type` | |
| `:story/editor` | — | |
| `:twofa/auth/password/check` | `track_id`, `phone` | без авторизации, не корневой экран |
| `:twofa/password/check` | — | |
| `:unknown-call` | `call_id`, `caller_id` | |
| `:videoweb/full` | `chat_id`, `msg_id` | |
| `:webapp:root` | `bot_id`, `entry_point` | |
| `:webview/faq` | — | без авторизации, не корневой экран |
## Как это работает в Komet
Точка входа одна для всех случаев: `MaxLink.parse()` в `lib/core/links/max_link.dart`
разбирает ссылку в sealed-тип, `tryHandleMaxLink()` в
`lib/frontend/widgets/max_link_handler.dart` его исполняет. Через неё идут и внешние
диплинки (`DeepLinkService``app_links`), и тапы внутри приложения
(`openExternalUrl` → текст сообщений, био, описания каналов, inline-кнопки, упоминания).
Ссылки без схемы (`max.ru/…`) распознаются и парсером, и автолинковкой в тексте.
Ссылки, которым не нужен сервер (маршруты, `:share`, папки, вкладки), больше не ждут
подключения — `DeepLinkService` проверяет `MaxLink.needsConnection`.
Манифест Komet уже ловит `https/http max.ru` (+ `www.`) и схемы `komet://`, `max://`.
### Контентные ссылки
| Ссылка | Поведение в Komet |
|---|---|
| `max.ru` | возврат на корневой экран |
| `max.ru/<username>`, `@<nickname>`, `u/<id>` | резолв через `LINK_INFO` → чат, канал или профиль |
| `?start=<payload>` | диалог с ботом + автоотправка `botStarted` |
| `?startapp=<payload>` | мини-приложение бота (`WebAppScreen`), botId берётся из резолва ссылки |
| `join/<code>` | подтверждение вступления и переход в чат |
| `joincall/<code>` | экран входа в звонок |
| `stickerset/<id>` | шит стикерпака |
| `<username>/<messageId>`, `c/<chatId>/<messageId>` | чат с переходом к сообщению (время берётся из `LINK_INFO`, иначе догружаем историю назад) |
| `?uid=<userId>` | профиль контакта |
| `?cid=<chatId>` | чат по серверному id |
| `:folder?id=` | корневой список с выбранной папкой |
| `:auth/<token>` | подтверждение веб-логина (в отличие от MAX токен не отбрасывается — он нужен нашему флоу) |
| `:current` | ничего не делает, как в MAX |
| `:share?text=` | панель пересылки → выбранный чат с текстом в поле ввода |
| `:share-self-out` | системный шэринг своей публичной ссылки |
| чужой хост | внешний браузер |
### Внутренние маршруты
Реализованы в `lib/frontend/widgets/max_route_handler.dart`:
| Маршрут | Экран Komet |
|---|---|
| `:chat-list`, `:settings/folder-list` | список чатов |
| `:calls-history`, `:call-list` | вкладка звонков |
| `:contact-list` | вкладка контактов |
| `:settings` | вкладка настроек |
| `:chats-search` | поиск |
| `:saved-messages` | Избранное |
| `:chats?id=` | чат |
| `:profile?id=&type=` | профиль контакта или чата (по `type`) |
| `:profile/members`, `:profile/avatars` | тот же профиль |
| `:profile/attaches?id=` | профиль на вкладке медиа |
| `:profile/edit` | редактирование своего профиля |
| `:scheduled-messages?id=` | отложенные сообщения чата |
| `:stickers/set?set_id=` | шит стикерпака |
| `:webapp:root?bot_id=&entry_point=`, `:settings/webapp?bot_id=` | мини-приложение |
| `:location/show?lat=&lon=&z=` | карта |
| `:qr-scanner` | сканер QR |
| `:settings/appearance` | внешний вид |
| `:settings/notifications` (+ `/chat`, `/dialog`, `/other`) | уведомления |
| `:settings/devices` | устройства |
| `:settings/aboutapp` | о приложении |
| `:settings/privacy`, `:settings/privacy/pincode`, `:settings/blacklist` | безопасность |
| `:settings/messages` | действия с сообщениями |
| `:settings/dev` (+ подэкраны) | отладочное меню |
| `:link-intercept`, `:external_callback` | no-op |
Остальные маршруты из таблицы выше показывают уведомление
«Ссылка не поддерживается: `<маршрут>`» — в Komet для них нет экрана. Сознательно не
подключены: `:login`/`:logout` (деструктивно), `:polls/*`, `:comments`, `:chats/forward`,
`:stories/*`, `:call-*` (кроме списка), `:twofa/*`, `:complaint`, `:media-*`,
`:attach/viewer`, `:videoweb/full`, `:invite/*` — им нужны либо чужие экраны, либо
контекст, которого в ссылке нет. Флаг `?externalCallback=1` не обрабатывается.
+59
View File
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/backend/modules/messages.dart';
import 'package:komet/frontend/widgets/message_bubble.dart';
import 'package:komet/l10n/app_localizations.dart';
const int _me = 1;
CachedMessage _botStart({String? payload}) => CachedMessage.fromPushPayload(
_me,
2,
{
'id': '5005',
'time': DateTime(2026, 1, 1, 18, 6).millisecondsSinceEpoch,
'type': 'USER',
'sender': _me,
'text': payload ?? '',
'attaches': [
{'_type': 'CONTROL', 'event': 'botStarted'},
],
},
);
Future<void> _pump(WidgetTester tester, CachedMessage message) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('ru'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Align(
alignment: Alignment.topLeft,
child: MessageBubble(
message: message,
isMe: true,
myId: _me,
chatType: 'DIALOG',
),
),
),
),
);
await tester.pump();
}
void main() {
testWidgets('a start with a payload shows a service line', (tester) async {
await _pump(tester, _botStart(payload: 'abc123'));
expect(find.text('Бот запущен: abc123'), findsOneWidget);
});
testWidgets('a start without a payload takes no space', (tester) async {
await _pump(tester, _botStart());
expect(find.textContaining('Бот запущен'), findsNothing);
expect(tester.getSize(find.byType(MessageBubble)), Size.zero);
});
}
+45 -6
View File
@@ -8,7 +8,7 @@ void main() {
CachedMessage parse(Map<String, dynamic> message) =>
CachedMessage.fromPushPayload(1001, 2002, message);
test('is a control message and a start marker', () {
test('is control and shows the payload the server put into text', () {
final message = parse({
'id': '3003',
'time': 1700000000000,
@@ -21,10 +21,47 @@ void main() {
});
expect(message.isControl, isTrue);
expect(message.isBotStartMarker, isTrue);
expect(message.botStartPayload, 'abc123');
expect(message.isSilentBotStart, isFalse);
});
test('other control events are not start markers', () {
test('reads the payload off the attach when it is there', () {
final message = parse({
'id': '3006',
'time': 1700000000000,
'type': 'USER',
'sender': 1001,
'attaches': [
{
'_type': 'CONTROL',
'event': 'botStarted',
'startPayload': 'abc123',
},
],
});
expect(message.botStartPayload, 'abc123');
expect(message.isSilentBotStart, isFalse);
});
test('a start without a payload stays hidden', () {
final message = parse({
'id': '3007',
'time': 1700000000000,
'type': 'USER',
'sender': 1001,
'text': '',
'attaches': [
{'_type': 'CONTROL', 'event': 'botStarted'},
],
});
expect(message.isControl, isTrue);
expect(message.botStartPayload, isNull);
expect(message.isSilentBotStart, isTrue);
});
test('other control events are untouched', () {
final message = parse({
'id': '3004',
'time': 1700000000000,
@@ -36,10 +73,11 @@ void main() {
});
expect(message.isControl, isTrue);
expect(message.isBotStartMarker, isFalse);
expect(message.botStartPayload, isNull);
expect(message.isSilentBotStart, isFalse);
});
test('a plain message is neither', () {
test('a plain message is not a start at all', () {
final message = parse({
'id': '3005',
'time': 1700000000000,
@@ -50,7 +88,8 @@ void main() {
});
expect(message.isControl, isFalse);
expect(message.isBotStartMarker, isFalse);
expect(message.botStartPayload, isNull);
expect(message.isSilentBotStart, isFalse);
});
test('the event name used on the wire stays stable', () {
+124 -32
View File
@@ -35,58 +35,150 @@ void main() {
expect(linkTarget('http://max.ru/somebot'), 'http://max.ru/somebot');
expect(linkTarget('https://max.ru/somebot'), 'https://max.ru/somebot');
});
});
test('a bare link is parsed as a max link once normalized', () {
final link = MaxLink.parse(linkTarget('max.ru/somebot?start=abc123'));
group('MaxLink.parse — не наши ссылки', () {
test('rejects other hosts and non-links', () {
expect(MaxLink.parse('https://example.com/somebot'), isNull);
expect(MaxLink.parse('evil.max.ru/phish'), isNull);
expect(MaxLink.parse('mailto:bot@max.ru'), isNull);
expect(MaxLink.parse(''), isNull);
});
expect(link!.kind, MaxLinkKind.public);
expect(link.startPayload, 'abc123');
test('rejects reserved site pages', () {
expect(MaxLink.parse('https://max.ru/login'), isNull);
expect(MaxLink.parse('https://max.ru/tos'), isNull);
});
});
group('MaxLink start payload', () {
test('parses a bot start link over http', () {
final link = MaxLink.parse('http://max.ru/id100000000001bot?start=abc123');
expect(link, isNotNull);
expect(link!.kind, MaxLinkKind.public);
expect(link.startPayload, 'abc123');
expect(link.baseUrl, 'https://max.ru/id100000000001bot');
expect(link.url, 'http://max.ru/id100000000001bot?start=abc123');
group('MaxLink.parse — контентные ссылки', () {
test('bare host, http and www all normalize to the root link', () {
expect(MaxLink.parse('max.ru'), isA<MaxRootLink>());
expect(MaxLink.parse('http://max.ru/'), isA<MaxRootLink>());
expect(MaxLink.parse('https://www.max.ru'), isA<MaxRootLink>());
expect(MaxLink.parse('max://max.ru/'), isA<MaxRootLink>());
});
test('decodes the payload and ignores a trailing fragment', () {
final link = MaxLink.parse(
'https://www.max.ru/somebot?start=a%20b&ref=x#top',
);
test('public link keeps the canonical https url', () {
final link = MaxLink.parse('max.ru/somebot') as MaxContentLink;
expect(link!.startPayload, 'a b');
expect(link.kind, MaxContentKind.public);
expect(link.url, 'https://max.ru/somebot');
expect(link.baseUrl, 'https://max.ru/somebot');
expect(link.startPayload, isNull);
expect(link.messageId, isNull);
});
test('@nickname is a public link too', () {
final link = MaxLink.parse('https://max.ru/@somebot') as MaxContentLink;
expect(link.kind, MaxContentKind.public);
expect(link.baseUrl, 'https://max.ru/somebot');
});
test('keeps the payload empty for links without one', () {
expect(MaxLink.parse('https://max.ru/somebot')!.startPayload, isNull);
test('bot start payload is parsed and the base url drops it', () {
final link =
MaxLink.parse('http://max.ru/id100000000001_bot?start=a%20b')
as MaxContentLink;
expect(link.startPayload, 'a b');
expect(link.url, 'https://max.ru/id100000000001_bot?start=a%20b');
expect(link.baseUrl, 'https://max.ru/id100000000001_bot');
});
test('empty start payload is ignored', () {
final link = MaxLink.parse('max.ru/somebot?start=') as MaxContentLink;
expect(link.startPayload, isNull);
});
test('startapp opens a mini app and is cut at the first &', () {
final link =
MaxLink.parse('max.ru/somebot?startapp=deal%2F42&ref=x')
as MaxWebAppLink;
expect(link.startApp, 'deal/42');
});
test('message links carry the message id', () {
final byName =
MaxLink.parse('max.ru/somechannel/117008613873053494')
as MaxContentLink;
expect(byName.kind, MaxContentKind.public);
expect(byName.messageId, 117008613873053494);
expect(byName.baseUrl, 'https://max.ru/somechannel');
final byId =
MaxLink.parse('max.ru/c/1673760/117008613873053494')
as MaxContentLink;
expect(byId.kind, MaxContentKind.content);
expect(byId.messageId, 117008613873053494);
expect(byId.baseUrl, 'https://max.ru/c/1673760');
});
test('invite, call and sticker links keep their own types', () {
expect(
MaxLink.parse('https://max.ru/somebot?start=')!.startPayload,
isNull,
(MaxLink.parse('max.ru/join/AbCdEf') as MaxContentLink).kind,
MaxContentKind.invite,
);
expect(MaxLink.parse('max.ru/joincall/AbCdEf'), isA<MaxCallLink>());
expect(
MaxLink.parse('https://max.ru/somebot?other=1')!.startPayload,
isNull,
(MaxLink.parse('max.ru/stickerset/512-abc') as MaxStickerSetLink).path,
'stickerset/512-abc',
);
});
test('leaves other link kinds untouched', () {
final invite = MaxLink.parse('https://max.ru/join/AbCdEf?start=x');
test('uid and cid open a contact and a chat', () {
expect(
(MaxLink.parse('max://max.ru/?uid=105587131') as MaxContactIdLink)
.userId,
105587131,
);
final chat = MaxLink.parse('max://max.ru/?cid=1673760') as MaxChatIdLink;
expect(chat.chatId, 1673760);
expect(chat.messageId, isNull);
});
});
expect(invite!.kind, MaxLinkKind.invite);
expect(invite.startPayload, isNull);
expect(invite.baseUrl, invite.url);
group('MaxLink.parse — внутренние маршруты', () {
test('auth keeps the full url with its token', () {
final link = MaxLink.parse('https://max.ru/:auth/tok3n') as MaxAuthLink;
expect(link.url, 'https://max.ru/:auth/tok3n');
});
test('still rejects non-max links', () {
expect(MaxLink.parse('https://example.com/somebot?start=x'), isNull);
expect(MaxLink.parse('https://max.ru/?start=x'), isNull);
test('share and share-self-out are separate targets', () {
final share =
MaxLink.parse('https://max.ru/:share?text=%D0%BF%D1%80%D0%B8%D0%B2')
as MaxShareTextLink;
expect(share.text, 'прив');
expect(
MaxLink.parse('https://max.ru/:share-self-out'),
isA<MaxShareSelfLink>(),
);
});
test('folder needs an id, otherwise it stays a plain route', () {
expect(
(MaxLink.parse('max.ru/:folder?id=42') as MaxFolderLink).folderId,
'42',
);
expect(MaxLink.parse('max.ru/:folder'), isA<MaxRouteLink>());
});
test('current is a no-op target', () {
expect(MaxLink.parse('max.ru/:current'), isA<MaxCurrentLink>());
});
test('other routes keep their path and query params', () {
final route =
MaxLink.parse('max://max.ru/:profile?id=123&type=CHAT')
as MaxRouteLink;
expect(route.route, ':profile');
expect(route.params, {'id': '123', 'type': 'CHAT'});
final nested =
MaxLink.parse('https://max.ru/:Settings/Appearance') as MaxRouteLink;
expect(nested.route, ':settings/appearance');
});
});
}