feat(links): поддержка max.ru-ссылок и komet:// диплинков

- резолв max.ru/{join,joincall,u,c,:auth,<channel>}
- открытие чатов/профилей/звонков и подтверждение веб-входа в приложении
- внешние диплинки: intent-filter (Android), URL-схемы (iOS/macOS), komet://-регистрация в рантайме (Windows/Linux)
- fix(calls): не слать opcode 79 до login (жижа я тебя выебу)
This commit is contained in:
klockky
2026-06-23 14:18:51 +03:00
parent 7f434dfd81
commit 4277b0a20d
16 changed files with 646 additions and 112 deletions
+16
View File
@@ -48,6 +48,22 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https" android:host="max.ru"/>
<data android:scheme="https" android:host="www.max.ru"/>
<data android:scheme="http" android:host="max.ru"/>
<data android:scheme="http" android:host="www.max.ru"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="komet"/>
<data android:scheme="max"/>
</intent-filter>
</activity>
<activity-alias
android:name=".MinimalIcon"
+12
View File
@@ -70,5 +70,17 @@
<string>Доступ к галерее нужен, чтобы отправлять фото и видео в чатах.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Доступ к галерее нужен, чтобы сохранять полученные фото и видео.</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>ru.komet.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>komet</string>
<string>max</string>
</array>
</dict>
</array>
</dict>
</plist>
+11 -1
View File
@@ -428,11 +428,20 @@ class LoginResult {
class AccountModule {
final Api _api;
final _loginStatusController = StreamController<LoginStatus>.broadcast();
bool _loggedIn = false;
AccountModule(this._api);
AccountModule(this._api) {
_api.stateStream.listen((state) {
if (state != SessionState.online) _loggedIn = false;
});
}
Stream<LoginStatus> get loginStatusStream => _loginStatusController.stream;
/// `true`, только когда сервер считает сессию ONLINE — после успешного
/// login (opcode 19), а не просто после хэндшейка (opcode 6).
bool get isLoggedIn => _loggedIn;
Future<PrivacyConfig> 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) {
+72
View File
@@ -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<dynamic, dynamic> chat;
final Map<dynamic, dynamic>? message;
const ResolvedChat(this.chat, this.message);
}
class ResolvedUser extends ResolvedLink {
final Map<dynamic, dynamic> contact;
const ResolvedUser(this.contact);
}
class ResolvedLinkError extends ResolvedLink {
final String message;
const ResolvedLinkError(this.message);
}
abstract class LinkModule {
static Future<ResolvedLink?> 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<String?> 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;
}
}
}
+91
View File
@@ -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<Uri>? _sub;
StreamSubscription<SessionState>? _stateSub;
String? _pending;
bool _ready = false;
bool _started = false;
Future<void> 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 = <String>[
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;
}
}
+73
View File
@@ -0,0 +1,73 @@
import 'dart:io';
import '../utils/logger.dart';
const List<String> _schemes = ['komet', 'max'];
abstract class DesktopUrlScheme {
static Future<void> register() async {
try {
if (Platform.isWindows) {
await _registerWindows();
} else if (Platform.isLinux) {
await _registerLinux();
}
} catch (e) {
logger.w('DesktopUrlScheme: регистрация не удалась: $e');
}
}
static Future<void> _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<void> _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<void> _run(String executable, List<String> args) async {
try {
await Process.run(executable, args);
} catch (_) {}
}
}
+58
View File
@@ -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<String> _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);
}
}
+2 -2
View File
@@ -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<void> 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);
+1 -2
View File
@@ -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<CallsTab> {
@override
void initState() {
super.initState();
if (api.state == SessionState.online) {
if (accountModule.isLoggedIn) {
_loadHistory();
} else {
_loginSub = accountModule.loginStatusStream.listen((status) {
@@ -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<DevicesScreen>
}
}
Future<bool> _confirmQrWebLoginSheet() async {
final agreed = await showModalBottomSheet<bool>(
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<void> _startWebQrAuth() async {
final canScan =
!kIsWeb &&
@@ -197,43 +132,8 @@ class _DevicesScreenState extends State<DevicesScreen>
if (!mounted) return;
if (qr == null || qr.trim().isEmpty) return;
final confirmed = await _confirmQrWebLoginSheet();
if (!mounted) return;
if (!confirmed) return;
showDialog<void>(
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<void> _terminateOthers() async {
+132
View File
@@ -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<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);
}
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<dynamic, dynamic> 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<void> _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<dynamic, dynamic> 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 'Профиль';
}
+115
View File
@@ -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<bool> showWebQrLoginConfirmSheet(BuildContext context) async {
final agreed = await showModalBottomSheet<bool>(
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<bool> confirmAndAuthorizeWebQrLogin(
BuildContext context,
String qrLink,
) async {
final confirmed = await showWebQrLoginConfirmSheet(context);
if (!confirmed || !context.mounted) return false;
showDialog<void>(
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;
}
}
+3
View File
@@ -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<KometApp>
_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);
+12
View File
@@ -28,5 +28,17 @@
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>ru.komet.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>komet</string>
<string>max</string>
</array>
</dict>
</array>
</dict>
</plist>
+44 -4
View File
@@ -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:
+1
View File
@@ -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