From 25f13f0492a3b888a696ee69a1a6e4b85baac9e2 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Thu, 30 Jul 2026 21:23:20 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B2=D1=80=D0=BE=D0=B4=D1=8C=20=D0=B4?= =?UTF-8?q?=D0=B8=D0=BF=D0=BB=D0=B8=D0=BD=D0=BA=D0=B8=20=D0=B8=20/start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 14 +- lib/core/links/deep_link_service.dart | 5 +- lib/core/links/max_link.dart | 305 +++++++++++++++--- .../screens/chats/chat_list_screen.dart | 81 +++-- lib/frontend/screens/chats/chat_screen.dart | 7 +- .../attachment/bubbles/control_bubble.dart | 6 + lib/frontend/widgets/max_link_handler.dart | 153 ++++++--- lib/frontend/widgets/max_link_nav.dart | 172 ++++++++++ lib/frontend/widgets/max_route_handler.dart | 205 ++++++++++++ lib/frontend/widgets/message_bubble.dart | 2 +- lib/models/attachment.dart | 4 + max-deeplinks.md | 288 +++++++++++++++++ test/bot_start_bubble_test.dart | 59 ++++ test/bot_start_control_test.dart | 51 ++- test/max_link_test.dart | 156 +++++++-- 15 files changed, 1352 insertions(+), 156 deletions(-) create mode 100644 lib/frontend/widgets/max_link_nav.dart create mode 100644 lib/frontend/widgets/max_route_handler.dart create mode 100644 max-deeplinks.md create mode 100644 test/bot_start_bubble_test.dart diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 9ca17a4..260efc2 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -412,11 +412,19 @@ class CachedMessage { this.editHistory, }); - bool get isBotStartMarker { - final control = attachments?.whereType().firstOrNull; - return control != null && control.isBotStart; + ControlAttachment? get controlAttachment => + attachments?.whereType().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, diff --git a/lib/core/links/deep_link_service.dart b/lib/core/links/deep_link_service.dart index 7f4956f..9a533a6 100644 --- a/lib/core/links/deep_link_service.dart +++ b/lib/core/links/deep_link_service.dart @@ -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); } diff --git a/lib/core/links/max_link.dart b/lib/core/links/max_link.dart index 3539737..e0f1a55 100644 --- a/lib/core/links/max_link.dart +++ b/lib/core/links/max_link.dart @@ -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 _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 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 segments, + Map 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 segments, + Map 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 params) { + final value = params['start']?.trim(); return (value == null || value.isEmpty) ? null : value; } + + static int? _idParam(Map 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 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; } diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index fd937e6..b10008e 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -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 createState() => _ChatListScreenState(); } @@ -553,6 +583,7 @@ class _ChatListScreenState extends State @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 @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 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 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 ); } + void _openSearch() => + unawaited(pushSwipeable(context, (_) => const SearchScreen())); + void _openSavedMessages() { CachedChat? self; for (final c in _chats) { diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index e034795..c414ac3 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -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 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( diff --git a/lib/frontend/widgets/attachment/bubbles/control_bubble.dart b/lib/frontend/widgets/attachment/bubbles/control_bubble.dart index b68af20..d69db12 100644 --- a/lib/frontend/widgets/attachment/bubbles/control_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/control_bubble.dart @@ -96,6 +96,12 @@ class _ControlBubbleState extends State { 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 ?? ''), diff --git a/lib/frontend/widgets/max_link_handler.dart b/lib/frontend/widgets/max_link_handler.dart index 2d32ffa..20ce065 100644 --- a/lib/frontend/widgets/max_link_handler.dart +++ b/lib/frontend/widgets/max_link_handler.dart @@ -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 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 _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 _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 tryHandleMaxLink(BuildContext context, String url) async { } } -Future _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 _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 _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 _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 _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 _openContact( BuildContext context, - MaxLink link, + MaxContentLink link, Map contact, ) async { final id = contact['id']; @@ -112,8 +168,7 @@ Future _startBotDialog( Map 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 _openResolvedChat( BuildContext context, - MaxLink link, + MaxContentLink link, ResolvedChat resolved, ) async { final chat = resolved.chat; @@ -170,14 +225,15 @@ Future _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 _openResolvedChat( return; } + final target = _messageTarget(link, resolved.message); pushSwipeable( context, (_) => ChatScreen( @@ -218,10 +275,26 @@ Future _openResolvedChat( imageUrl: icon, chatType: type, channelSubscribed: type == 'CHANNEL' ? isMember : null, + initialMessageId: target?.id, + initialMessageTime: target?.time, ), ); } +({String id, int? time})? _messageTarget( + MaxContentLink link, + Map? 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 contact) { final names = contact['names']; if (names is List && names.isNotEmpty && names.first is Map) { diff --git a/lib/frontend/widgets/max_link_nav.dart b/lib/frontend/widgets/max_link_nav.dart new file mode 100644 index 0000000..6076c8b --- /dev/null +++ b/lib/frontend/widgets/max_link_nav.dart @@ -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 popToAppRootAndSettle(BuildContext context) async { + popToAppRoot(context); + await WidgetsBinding.instance.endOfFrame; + return KometApp.navigatorKey.currentContext; +} + +Future currentAccountId() async { + final profile = await AppDatabase.loadActiveProfile(); + return profile?.id ?? 0; +} + +Future 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 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 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 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 openStickerSetById(BuildContext context, int setId) async { + await showStickerPackSheet(context, knownSetId: setId); + return true; +} + +Future 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 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 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 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, 'Сначала войдите в аккаунт'); diff --git a/lib/frontend/widgets/max_route_handler.dart b/lib/frontend/widgets/max_route_handler.dart new file mode 100644 index 0000000..e6d6bc5 --- /dev/null +++ b/lib/frontend/widgets/max_route_handler.dart @@ -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 openMaxRoute( + BuildContext context, + String route, + Map 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 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 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 _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; +} diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 238036b..ad0cba1 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -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( diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index b1287ce..ea0c5af 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -435,6 +435,7 @@ class ControlAttachment extends MessageAttachment { final String? title; final List? 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, }; } diff --git a/max-deeplinks.md b/max-deeplinks.md new file mode 100644 index 0000000..16226b9 --- /dev/null +++ b/max-deeplinks.md @@ -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/` | чат/канал/бота по публичной ссылке; если такой чат уже локально есть — открывает его напрямую, иначе резолвит на сервере | `JoinLink` → `ShowChat` / `ShowContact` / `ConfirmJoin` | +| `max.ru/@` | то же, ник ищется среди ссылок известных чатов | → чат либо `UnknownContact` | +| `max.ru/?start=` | диалог с ботом + автозапуск: отправляется `botStarted` со `startPayload` | `ShowContactDialog(chatId, startPayload, externalCallback)` | +| `max.ru/?startapp=` | мини-приложение бота (payload обрезается по первому `&`, query из ссылки вычищается) | `StartWebAppLink` → `OpenWebApp(botId, startParam)` / `ErrorWebAppNotExist` | +| `max.ru/join/` | вступление по приватной инвайт-ссылке | `JoinLink` → `ConfirmJoin` / `ShowChat` | +| `max.ru/joincall/` | экран входа в звонок по ссылке | `CallJoinLink` → `ShowJoinCall` | +| `max.ru/stickerset/` | стикерпак; `id` берётся до первого `-` | `StickerSet` → `ShowStickerSet` | +| `max.ru//` | чат на конкретном сообщении (второй сегмент должен быть числом) | `MessagestLink` → `ShowChat(chatId, messageId)` | +| `max.ru/c//` | сообщение/пост в чате по числовым id | `MessagestLink` → `ShowChat` | +| `max.ru/:folder?id=` | список чатов в папке | `FolderChatList` → `OpenChatListInFolder`, иначе `UnknownFolderError` | +| `max.ru/?uid=` | контакт/диалог по id пользователя (ищется локально) | `DeepLinkData(contactId)` | +| `max.ru/?cid=` | чат по серверному 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=` | шэринг текста внутрь приложения (выбор чата) | маршрут `: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/`, `@`, `u/` | резолв через `LINK_INFO` → чат, канал или профиль | +| `?start=` | диалог с ботом + автоотправка `botStarted` | +| `?startapp=` | мини-приложение бота (`WebAppScreen`), botId берётся из резолва ссылки | +| `join/` | подтверждение вступления и переход в чат | +| `joincall/` | экран входа в звонок | +| `stickerset/` | шит стикерпака | +| `/`, `c//` | чат с переходом к сообщению (время берётся из `LINK_INFO`, иначе догружаем историю назад) | +| `?uid=` | профиль контакта | +| `?cid=` | чат по серверному id | +| `:folder?id=` | корневой список с выбранной папкой | +| `:auth/` | подтверждение веб-логина (в отличие от 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` не обрабатывается. diff --git a/test/bot_start_bubble_test.dart b/test/bot_start_bubble_test.dart new file mode 100644 index 0000000..82bdb4f --- /dev/null +++ b/test/bot_start_bubble_test.dart @@ -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 _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); + }); +} diff --git a/test/bot_start_control_test.dart b/test/bot_start_control_test.dart index 9c56502..3265920 100644 --- a/test/bot_start_control_test.dart +++ b/test/bot_start_control_test.dart @@ -8,7 +8,7 @@ void main() { CachedMessage parse(Map 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', () { diff --git a/test/max_link_test.dart b/test/max_link_test.dart index d5dcbb3..e12c095 100644 --- a/test/max_link_test.dart +++ b/test/max_link_test.dart @@ -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()); + expect(MaxLink.parse('http://max.ru/'), isA()); + expect(MaxLink.parse('https://www.max.ru'), isA()); + expect(MaxLink.parse('max://max.ru/'), isA()); }); - 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()); 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(), + ); + }); + + 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()); + }); + + test('current is a no-op target', () { + expect(MaxLink.parse('max.ru/:current'), isA()); + }); + + 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'); }); }); }