diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a6d170c..20f7944 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -48,6 +48,22 @@ + + + + + + + + + + + + + + + + Доступ к галерее нужен, чтобы отправлять фото и видео в чатах. NSPhotoLibraryAddUsageDescription Доступ к галерее нужен, чтобы сохранять полученные фото и видео. + CFBundleURLTypes + + + CFBundleURLName + ru.komet.app + CFBundleURLSchemes + + komet + max + + + diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 7822ab3..692f5e6 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -428,11 +428,20 @@ class LoginResult { class AccountModule { final Api _api; final _loginStatusController = StreamController.broadcast(); + bool _loggedIn = false; - AccountModule(this._api); + AccountModule(this._api) { + _api.stateStream.listen((state) { + if (state != SessionState.online) _loggedIn = false; + }); + } Stream get loginStatusStream => _loginStatusController.stream; + /// `true`, только когда сервер считает сессию ONLINE — после успешного + /// login (opcode 19), а не просто после хэндшейка (opcode 6). + bool get isLoggedIn => _loggedIn; + Future getPrivacyConfig() async { final accountId = await TokenStorage.getActiveAccountId(); if (accountId != null) { @@ -999,6 +1008,7 @@ class AccountModule { } final result = await _processLoginResponse(dataMap, resolvedAccountId); + _loggedIn = true; _loginStatusController.add(LoginStatus.success); return result; } catch (e) { diff --git a/lib/backend/modules/links.dart b/lib/backend/modules/links.dart new file mode 100644 index 0000000..4edfe9b --- /dev/null +++ b/lib/backend/modules/links.dart @@ -0,0 +1,72 @@ +import 'dart:async'; + +import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; +import '../api.dart'; + +sealed class ResolvedLink { + const ResolvedLink(); +} + +class ResolvedChat extends ResolvedLink { + final Map chat; + final Map? message; + + const ResolvedChat(this.chat, this.message); +} + +class ResolvedUser extends ResolvedLink { + final Map contact; + + const ResolvedUser(this.contact); +} + +class ResolvedLinkError extends ResolvedLink { + final String message; + + const ResolvedLinkError(this.message); +} + +abstract class LinkModule { + static Future resolve(Api api, String url) async { + final Packet response; + try { + response = await api.sendRequest(Opcode.linkInfo, {'link': url}); + } on TimeoutException { + return const ResolvedLinkError('Превышено время ожидания'); + } on PacketError catch (e) { + return ResolvedLinkError(e.message); + } + + final payload = response.payload; + if (payload is! Map) return null; + if (!response.isOk) { + return ResolvedLinkError(messageFromErrorPayload(payload)); + } + + final chat = payload['chat']; + if (chat is Map) { + final message = payload['message']; + return ResolvedChat(chat, message is Map ? message : null); + } + + final user = payload['user']; + if (user is Map && user['contact'] is Map) { + return ResolvedUser(user['contact'] as Map); + } + + return null; + } + + static Future join(Api api, String url) async { + try { + final response = await api.sendRequest(Opcode.chatJoin, {'link': url}); + if (response.isOk) return null; + return messageFromErrorPayload(response.payload); + } on TimeoutException { + return 'Превышено время ожидания'; + } on PacketError catch (e) { + return e.message; + } + } +} diff --git a/lib/core/links/deep_link_service.dart b/lib/core/links/deep_link_service.dart new file mode 100644 index 0000000..133aecc --- /dev/null +++ b/lib/core/links/deep_link_service.dart @@ -0,0 +1,91 @@ +import 'dart:async'; + +import 'package:app_links/app_links.dart'; + +import '../../backend/api.dart'; +import '../../frontend/widgets/max_link_handler.dart'; +import '../../main.dart'; +import 'desktop_url_scheme.dart'; + +class DeepLinkService { + DeepLinkService._(); + + static final DeepLinkService instance = DeepLinkService._(); + + final AppLinks _appLinks = AppLinks(); + StreamSubscription? _sub; + StreamSubscription? _stateSub; + String? _pending; + bool _ready = false; + bool _started = false; + + Future init() async { + if (_started) return; + _started = true; + + await DesktopUrlScheme.register(); + + _stateSub = api.stateStream.listen((state) { + if (state == SessionState.online) _flushPending(); + }); + + _sub = _appLinks.uriLinkStream.listen(_onUri); + try { + final initial = await _appLinks.getInitialLink(); + if (initial != null) _onUri(initial); + } catch (_) {} + } + + void markReady() { + _ready = true; + _flushPending(); + } + + void _onUri(Uri uri) { + final url = _normalize(uri); + if (url == null) return; + _pending = url; + _flushPending(); + } + + void _flushPending() { + final pending = _pending; + if (pending == null || !_ready) return; + if (api.state != SessionState.online) return; + final context = KometApp.navigatorKey.currentContext; + if (context == null) return; + + _pending = null; + tryHandleMaxLink(context, pending); + } + + String? _normalize(Uri uri) { + final scheme = uri.scheme.toLowerCase(); + + if (scheme == 'https' || scheme == 'http') { + final host = uri.host.toLowerCase(); + if (host == 'max.ru' || host == 'www.max.ru') return uri.toString(); + return null; + } + + if (scheme == 'komet' || scheme == 'max') { + final segments = [ + if (uri.host.isNotEmpty && uri.host.toLowerCase() != 'max.ru') uri.host, + ...uri.pathSegments, + ].where((s) => s.isNotEmpty).toList(); + if (segments.isEmpty) return null; + final query = uri.query.isNotEmpty ? '?${uri.query}' : ''; + return 'https://max.ru/${segments.join('/')}$query'; + } + + return null; + } + + void dispose() { + _sub?.cancel(); + _sub = null; + _stateSub?.cancel(); + _stateSub = null; + _started = false; + } +} diff --git a/lib/core/links/desktop_url_scheme.dart b/lib/core/links/desktop_url_scheme.dart new file mode 100644 index 0000000..074c5a9 --- /dev/null +++ b/lib/core/links/desktop_url_scheme.dart @@ -0,0 +1,73 @@ +import 'dart:io'; + +import '../utils/logger.dart'; + +const List _schemes = ['komet', 'max']; + +abstract class DesktopUrlScheme { + static Future register() async { + try { + if (Platform.isWindows) { + await _registerWindows(); + } else if (Platform.isLinux) { + await _registerLinux(); + } + } catch (e) { + logger.w('DesktopUrlScheme: регистрация не удалась: $e'); + } + } + + static Future _registerWindows() async { + final exe = Platform.resolvedExecutable; + for (final scheme in _schemes) { + final capitalized = '${scheme[0].toUpperCase()}${scheme.substring(1)}'; + final base = 'HKCU\\Software\\Classes\\$scheme'; + + await _run('reg', ['add', base, '/ve', '/d', 'URL:$capitalized', '/f']); + await _run('reg', ['add', base, '/v', 'URL Protocol', '/d', '', '/f']); + await _run('reg', [ + 'add', + '$base\\shell\\open\\command', + '/ve', + '/d', + '"$exe" "%1"', + '/f', + ]); + } + } + + static Future _registerLinux() async { + final home = Platform.environment['HOME']; + if (home == null || home.isEmpty) return; + + final exe = Platform.resolvedExecutable; + final appsDir = Directory('$home/.local/share/applications'); + await appsDir.create(recursive: true); + + const fileName = 'komet-url-handler.desktop'; + final mimeTypes = _schemes.map((s) => 'x-scheme-handler/$s').join(';'); + final desktop = '[Desktop Entry]\n' + 'Type=Application\n' + 'Name=Komet\n' + 'Exec="$exe" %u\n' + 'Terminal=false\n' + 'NoDisplay=true\n' + 'MimeType=$mimeTypes;\n'; + + final file = File('${appsDir.path}/$fileName'); + if (!file.existsSync() || await file.readAsString() != desktop) { + await file.writeAsString(desktop); + } + + for (final scheme in _schemes) { + await _run('xdg-mime', ['default', fileName, 'x-scheme-handler/$scheme']); + } + await _run('update-desktop-database', [appsDir.path]); + } + + static Future _run(String executable, List args) async { + try { + await Process.run(executable, args); + } catch (_) {} + } +} diff --git a/lib/core/links/max_link.dart b/lib/core/links/max_link.dart new file mode 100644 index 0000000..5f0a679 --- /dev/null +++ b/lib/core/links/max_link.dart @@ -0,0 +1,58 @@ +enum MaxLinkKind { call, invite, user, content, public, auth } + +class MaxLink { + final MaxLinkKind kind; + final String url; + + const MaxLink(this.kind, this.url); + + static final RegExp _host = RegExp( + r'^https?://(?: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', + 'privacy', + 'about', + 'help', + }; + + 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 path = match.group(1)!.split('?').first.split('#').first; + final segments = + path.split('/').where((s) => s.isNotEmpty).toList(growable: false); + if (segments.isEmpty) return null; + + switch (segments.first.toLowerCase()) { + 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; + } + + if (_reserved.contains(segments.first.toLowerCase())) return null; + if (!_segment.hasMatch(segments.first)) return null; + return MaxLink(MaxLinkKind.public, url); + } +} diff --git a/lib/core/utils/link_opener.dart b/lib/core/utils/link_opener.dart index 9fb0cc3..089fa3f 100644 --- a/lib/core/utils/link_opener.dart +++ b/lib/core/utils/link_opener.dart @@ -1,11 +1,11 @@ import 'package:flutter/widgets.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../../frontend/widgets/call_link_handler.dart'; import '../../frontend/widgets/custom_notification.dart'; +import '../../frontend/widgets/max_link_handler.dart'; Future openExternalUrl(BuildContext context, String url) async { - if (await tryHandleCallLink(context, url)) return; + if (await tryHandleMaxLink(context, url)) return; if (!context.mounted) return; final uri = Uri.tryParse(url); diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index b808a7c..3d0993a 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart' show api, accountModule; -import '../../../backend/api.dart'; import '../../../backend/modules/account.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/utils/format.dart'; @@ -27,7 +26,7 @@ class _CallsTabState extends State { @override void initState() { super.initState(); - if (api.state == SessionState.online) { + if (accountModule.isLoggedIn) { _loadHistory(); } else { _loginSub = accountModule.loginStatusStream.listen((status) { diff --git a/lib/frontend/screens/profile/devices_screen.dart b/lib/frontend/screens/profile/devices_screen.dart index 982128d..60ef135 100644 --- a/lib/frontend/screens/profile/devices_screen.dart +++ b/lib/frontend/screens/profile/devices_screen.dart @@ -12,7 +12,7 @@ import '../../../backend/modules/account.dart' show SessionInfo; import '../../widgets/custom_notification.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/glossy_pill.dart'; -import '../../widgets/sheet_helpers.dart'; +import '../../widgets/web_qr_login.dart'; import 'web_qr_scan_screen.dart'; class DevicesScreen extends StatefulWidget { @@ -113,71 +113,6 @@ class _DevicesScreenState extends State } } - Future _confirmQrWebLoginSheet() async { - final agreed = await showModalBottomSheet( - context: context, - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (sheetContext) { - final cs = Theme.of(sheetContext).colorScheme; - return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 12, 24, 24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Center(child: SheetGrabber(margin: EdgeInsets.zero)), - const SizedBox(height: 20), - Text( - 'Вход по QR', - style: GoogleFonts.outfit( - fontSize: 20, - fontWeight: FontWeight.w700, - color: cs.onSurface, - ), - ), - const SizedBox(height: 12), - Text( - 'Вы точно хотите войти в аккаунт через веб или приложение MAX на компьютере?', - style: TextStyle( - fontSize: 15, - height: 1.35, - color: cs.onSurfaceVariant, - ), - ), - const SizedBox(height: 28), - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: () => Navigator.of(sheetContext).pop(false), - child: Text( - 'Отмена', - style: TextStyle(color: cs.onSurface), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: FilledButton( - onPressed: () => Navigator.of(sheetContext).pop(true), - child: const Text('Войти'), - ), - ), - ], - ), - ], - ), - ), - ); - }, - ); - return agreed ?? false; - } - Future _startWebQrAuth() async { final canScan = !kIsWeb && @@ -197,43 +132,8 @@ class _DevicesScreenState extends State if (!mounted) return; if (qr == null || qr.trim().isEmpty) return; - final confirmed = await _confirmQrWebLoginSheet(); - if (!mounted) return; - if (!confirmed) return; - - showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) { - final cs = Theme.of(ctx).colorScheme; - return PopScope( - canPop: false, - child: Center( - child: Card( - color: cs.surfaceContainerHigh, - child: const Padding( - padding: EdgeInsets.all(28), - child: CircularProgressIndicator(), - ), - ), - ), - ); - }, - ); - - try { - await accountModule.authorizeWebQrLogin(qr.trim()); - if (mounted) { - Navigator.of(context, rootNavigator: true).pop(); - showCustomNotification(context, 'Вход по QR подтверждён'); - _loadSessions(); - } - } catch (e) { - if (mounted) { - Navigator.of(context, rootNavigator: true).pop(); - showCustomNotification(context, 'Ошибка: $e'); - } - } + final success = await confirmAndAuthorizeWebQrLogin(context, qr.trim()); + if (success && mounted) _loadSessions(); } Future _terminateOthers() async { diff --git a/lib/frontend/widgets/max_link_handler.dart b/lib/frontend/widgets/max_link_handler.dart new file mode 100644 index 0000000..227861b --- /dev/null +++ b/lib/frontend/widgets/max_link_handler.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; + +import '../../backend/modules/chats.dart'; +import '../../backend/modules/links.dart'; +import '../../core/links/max_link.dart'; +import '../../core/storage/app_database.dart'; +import '../../main.dart'; +import '../screens/chats/chat_screen.dart'; +import '../screens/contacts/contact_profile_screen.dart'; +import 'call_link_handler.dart'; +import 'confirm_dialog.dart'; +import 'custom_notification.dart'; +import 'swipe_route.dart'; +import 'web_qr_login.dart'; + +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); + } + + if (link.kind == MaxLinkKind.auth) { + await confirmAndAuthorizeWebQrLogin(context, link.url); + return true; + } + + final resolved = await LinkModule.resolve(api, link.url); + if (!context.mounted) return true; + + switch (resolved) { + case null: + return false; + case ResolvedLinkError(:final message): + showCustomNotification(context, message); + return true; + case ResolvedUser(:final contact): + _openContact(context, contact); + return true; + case ResolvedChat(): + await _openResolvedChat(context, link, resolved); + return true; + } +} + +void _openContact(BuildContext context, Map contact) { + final id = contact['id']; + if (id is! int) { + showCustomNotification(context, 'Не удалось открыть профиль'); + return; + } + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ContactProfileScreen( + contactId: id, + initialName: _contactName(contact), + initialAvatarUrl: contact['baseUrl'] as String?, + ), + ), + ); +} + +Future _openResolvedChat( + BuildContext context, + MaxLink link, + ResolvedChat resolved, +) async { + final chat = resolved.chat; + final id = chat['id']; + if (id is! int) { + showCustomNotification(context, 'Не удалось открыть чат'); + return; + } + + final title = (chat['title'] as String?)?.trim() ?? ''; + final type = (chat['type'] as String?) ?? 'CHAT'; + final icon = (chat['baseIconUrl'] as String?) ?? ''; + final access = chat['access']; + + final profile = await AppDatabase.loadActiveProfile(); + final myId = profile?.id ?? 0; + final participants = chat['participants']; + final isMember = myId != 0 && + participants is Map && + participants.containsKey(myId.toString()); + + await ChatsModule.cacheServerChat(chat, myId, inList: isMember); + if (!context.mounted) return; + + if (link.kind == MaxLinkKind.invite && access == 'PRIVATE' && !isMember) { + final label = title.isEmpty ? 'этот чат' : '«$title»'; + final confirmed = await showConfirmDialog( + context, + title: 'Вступить', + message: 'Вступить в $label?', + confirmLabel: 'Вступить', + ); + if (!confirmed || !context.mounted) return; + + final error = await LinkModule.join(api, link.url); + if (error != null) { + if (context.mounted) showCustomNotification(context, error); + return; + } + if (!context.mounted) return; + } + + pushSwipeable( + context, + (_) => ChatScreen( + chatId: id, + name: title, + imageUrl: icon, + chatType: type, + ), + ); +} + +String _contactName(Map contact) { + final names = contact['names']; + if (names is List && names.isNotEmpty && names.first is Map) { + final entry = names.first as Map; + final full = (entry['name'] as String?)?.trim(); + if (full != null && full.isNotEmpty) return full; + final first = (entry['firstName'] as String?)?.trim() ?? ''; + final last = (entry['lastName'] as String?)?.trim() ?? ''; + final joined = '$first $last'.trim(); + if (joined.isNotEmpty) return joined; + } + return 'Профиль'; +} diff --git a/lib/frontend/widgets/web_qr_login.dart b/lib/frontend/widgets/web_qr_login.dart new file mode 100644 index 0000000..3972632 --- /dev/null +++ b/lib/frontend/widgets/web_qr_login.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../main.dart' show accountModule; +import 'custom_notification.dart'; +import 'sheet_helpers.dart'; + +Future showWebQrLoginConfirmSheet(BuildContext context) async { + final agreed = await showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (sheetContext) { + final cs = Theme.of(sheetContext).colorScheme; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Center(child: SheetGrabber(margin: EdgeInsets.zero)), + const SizedBox(height: 20), + Text( + 'Вход по QR', + style: GoogleFonts.outfit( + fontSize: 20, + fontWeight: FontWeight.w700, + color: cs.onSurface, + ), + ), + const SizedBox(height: 12), + Text( + 'Вы точно хотите войти в аккаунт через веб или приложение MAX ' + 'на компьютере?', + style: TextStyle( + fontSize: 15, + height: 1.35, + color: cs.onSurfaceVariant, + ), + ), + const SizedBox(height: 28), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.of(sheetContext).pop(false), + child: Text( + 'Отмена', + style: TextStyle(color: cs.onSurface), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton( + onPressed: () => Navigator.of(sheetContext).pop(true), + child: const Text('Войти'), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + return agreed ?? false; +} + +Future confirmAndAuthorizeWebQrLogin( + BuildContext context, + String qrLink, +) async { + final confirmed = await showWebQrLoginConfirmSheet(context); + if (!confirmed || !context.mounted) return false; + + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) { + final cs = Theme.of(ctx).colorScheme; + return PopScope( + canPop: false, + child: Center( + child: Card( + color: cs.surfaceContainerHigh, + child: const Padding( + padding: EdgeInsets.all(28), + child: CircularProgressIndicator(), + ), + ), + ), + ); + }, + ); + + try { + await accountModule.authorizeWebQrLogin(qrLink.trim()); + if (context.mounted) { + Navigator.of(context, rootNavigator: true).pop(); + showCustomNotification(context, 'Вход подтверждён'); + } + return true; + } catch (e) { + if (context.mounted) { + Navigator.of(context, rootNavigator: true).pop(); + showCustomNotification(context, 'Не удалось подтвердить вход: $e'); + } + return false; + } +} diff --git a/lib/main.dart b/lib/main.dart index ea561f7..d4c0cdf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -44,6 +44,7 @@ import 'backend/modules/self_check.dart'; import 'backend/modules/webapp.dart'; import 'backend/modules/digital_id.dart'; import 'core/calls/call_controller.dart'; +import 'core/links/deep_link_service.dart'; import 'frontend/screens/calls/call_screen.dart'; import 'core/push/push_service.dart'; import 'core/storage/app_database.dart'; @@ -95,6 +96,7 @@ void main() async { } attachInfoCacheApi(api); ChatsModule.attachGlobalPushHandlers(api); + unawaited(DeepLinkService.instance.init()); final packageInfoFuture = PackageInfo.fromPlatform(); final localeFuture = _loadInitialLocale(); @@ -265,6 +267,7 @@ class KometAppState extends State _loginStatusSub = accountModule.loginStatusStream.listen((status) async { if (status == LoginStatus.success) { + DeepLinkService.instance.markReady(); CallController.instance.init(api); OutboxService.instance.init(api, messagesModule); SelfCheckService.instance.init(api); diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index 4789daa..0022fd7 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -28,5 +28,17 @@ MainMenu NSPrincipalClass NSApplication + CFBundleURLTypes + + + CFBundleURLName + ru.komet.app + CFBundleURLSchemes + + komet + max + + + diff --git a/pubspec.lock b/pubspec.lock index 08ac60e..8f7e772 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,38 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.2" + app_links: + dependency: "direct main" + description: + name: app_links + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" archive: dependency: transitive description: @@ -597,6 +629,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.3.3" + gtk: + dependency: transitive + description: + name: gtk + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" + url: "https://pub.dev" + source: hosted + version: "2.2.0" hooks: dependency: transitive description: @@ -785,10 +825,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mobile_scanner: dependency: "direct main" description: @@ -1174,10 +1214,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.10" timezone: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index fff9b38..f70dfa1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,6 +63,7 @@ dependencies: path_provider: ^2.1.4 open_filex: ^4.5.0 url_launcher: ^6.3.1 + app_links: ^6.3.0 video_player: ^2.9.2 firebase_core: ^4.1.1 firebase_messaging: ^16.0.2