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:
@@ -48,6 +48,22 @@
|
|||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</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>
|
||||||
<activity-alias
|
<activity-alias
|
||||||
android:name=".MinimalIcon"
|
android:name=".MinimalIcon"
|
||||||
|
|||||||
@@ -70,5 +70,17 @@
|
|||||||
<string>Доступ к галерее нужен, чтобы отправлять фото и видео в чатах.</string>
|
<string>Доступ к галерее нужен, чтобы отправлять фото и видео в чатах.</string>
|
||||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
<string>Доступ к галерее нужен, чтобы сохранять полученные фото и видео.</string>
|
<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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -428,11 +428,20 @@ class LoginResult {
|
|||||||
class AccountModule {
|
class AccountModule {
|
||||||
final Api _api;
|
final Api _api;
|
||||||
final _loginStatusController = StreamController<LoginStatus>.broadcast();
|
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;
|
Stream<LoginStatus> get loginStatusStream => _loginStatusController.stream;
|
||||||
|
|
||||||
|
/// `true`, только когда сервер считает сессию ONLINE — после успешного
|
||||||
|
/// login (opcode 19), а не просто после хэндшейка (opcode 6).
|
||||||
|
bool get isLoggedIn => _loggedIn;
|
||||||
|
|
||||||
Future<PrivacyConfig> getPrivacyConfig() async {
|
Future<PrivacyConfig> getPrivacyConfig() async {
|
||||||
final accountId = await TokenStorage.getActiveAccountId();
|
final accountId = await TokenStorage.getActiveAccountId();
|
||||||
if (accountId != null) {
|
if (accountId != null) {
|
||||||
@@ -999,6 +1008,7 @@ class AccountModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final result = await _processLoginResponse(dataMap, resolvedAccountId);
|
final result = await _processLoginResponse(dataMap, resolvedAccountId);
|
||||||
|
_loggedIn = true;
|
||||||
_loginStatusController.add(LoginStatus.success);
|
_loginStatusController.add(LoginStatus.success);
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../../frontend/widgets/call_link_handler.dart';
|
|
||||||
import '../../frontend/widgets/custom_notification.dart';
|
import '../../frontend/widgets/custom_notification.dart';
|
||||||
|
import '../../frontend/widgets/max_link_handler.dart';
|
||||||
|
|
||||||
Future<void> openExternalUrl(BuildContext context, String url) async {
|
Future<void> openExternalUrl(BuildContext context, String url) async {
|
||||||
if (await tryHandleCallLink(context, url)) return;
|
if (await tryHandleMaxLink(context, url)) return;
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
final uri = Uri.tryParse(url);
|
final uri = Uri.tryParse(url);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import '../../../main.dart' show api, accountModule;
|
import '../../../main.dart' show api, accountModule;
|
||||||
import '../../../backend/api.dart';
|
|
||||||
import '../../../backend/modules/account.dart';
|
import '../../../backend/modules/account.dart';
|
||||||
import '../../../core/storage/app_database.dart';
|
import '../../../core/storage/app_database.dart';
|
||||||
import '../../../core/utils/format.dart';
|
import '../../../core/utils/format.dart';
|
||||||
@@ -27,7 +26,7 @@ class _CallsTabState extends State<CallsTab> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (api.state == SessionState.online) {
|
if (accountModule.isLoggedIn) {
|
||||||
_loadHistory();
|
_loadHistory();
|
||||||
} else {
|
} else {
|
||||||
_loginSub = accountModule.loginStatusStream.listen((status) {
|
_loginSub = accountModule.loginStatusStream.listen((status) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import '../../../backend/modules/account.dart' show SessionInfo;
|
|||||||
import '../../widgets/custom_notification.dart';
|
import '../../widgets/custom_notification.dart';
|
||||||
import '../../widgets/connection_status.dart';
|
import '../../widgets/connection_status.dart';
|
||||||
import '../../widgets/glossy_pill.dart';
|
import '../../widgets/glossy_pill.dart';
|
||||||
import '../../widgets/sheet_helpers.dart';
|
import '../../widgets/web_qr_login.dart';
|
||||||
import 'web_qr_scan_screen.dart';
|
import 'web_qr_scan_screen.dart';
|
||||||
|
|
||||||
class DevicesScreen extends StatefulWidget {
|
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 {
|
Future<void> _startWebQrAuth() async {
|
||||||
final canScan =
|
final canScan =
|
||||||
!kIsWeb &&
|
!kIsWeb &&
|
||||||
@@ -197,43 +132,8 @@ class _DevicesScreenState extends State<DevicesScreen>
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (qr == null || qr.trim().isEmpty) return;
|
if (qr == null || qr.trim().isEmpty) return;
|
||||||
|
|
||||||
final confirmed = await _confirmQrWebLoginSheet();
|
final success = await confirmAndAuthorizeWebQrLogin(context, qr.trim());
|
||||||
if (!mounted) return;
|
if (success && mounted) _loadSessions();
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _terminateOthers() async {
|
Future<void> _terminateOthers() async {
|
||||||
|
|||||||
@@ -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 'Профиль';
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ import 'backend/modules/self_check.dart';
|
|||||||
import 'backend/modules/webapp.dart';
|
import 'backend/modules/webapp.dart';
|
||||||
import 'backend/modules/digital_id.dart';
|
import 'backend/modules/digital_id.dart';
|
||||||
import 'core/calls/call_controller.dart';
|
import 'core/calls/call_controller.dart';
|
||||||
|
import 'core/links/deep_link_service.dart';
|
||||||
import 'frontend/screens/calls/call_screen.dart';
|
import 'frontend/screens/calls/call_screen.dart';
|
||||||
import 'core/push/push_service.dart';
|
import 'core/push/push_service.dart';
|
||||||
import 'core/storage/app_database.dart';
|
import 'core/storage/app_database.dart';
|
||||||
@@ -95,6 +96,7 @@ void main() async {
|
|||||||
}
|
}
|
||||||
attachInfoCacheApi(api);
|
attachInfoCacheApi(api);
|
||||||
ChatsModule.attachGlobalPushHandlers(api);
|
ChatsModule.attachGlobalPushHandlers(api);
|
||||||
|
unawaited(DeepLinkService.instance.init());
|
||||||
|
|
||||||
final packageInfoFuture = PackageInfo.fromPlatform();
|
final packageInfoFuture = PackageInfo.fromPlatform();
|
||||||
final localeFuture = _loadInitialLocale();
|
final localeFuture = _loadInitialLocale();
|
||||||
@@ -265,6 +267,7 @@ class KometAppState extends State<KometApp>
|
|||||||
|
|
||||||
_loginStatusSub = accountModule.loginStatusStream.listen((status) async {
|
_loginStatusSub = accountModule.loginStatusStream.listen((status) async {
|
||||||
if (status == LoginStatus.success) {
|
if (status == LoginStatus.success) {
|
||||||
|
DeepLinkService.instance.markReady();
|
||||||
CallController.instance.init(api);
|
CallController.instance.init(api);
|
||||||
OutboxService.instance.init(api, messagesModule);
|
OutboxService.instance.init(api, messagesModule);
|
||||||
SelfCheckService.instance.init(api);
|
SelfCheckService.instance.init(api);
|
||||||
|
|||||||
@@ -28,5 +28,17 @@
|
|||||||
<string>MainMenu</string>
|
<string>MainMenu</string>
|
||||||
<key>NSPrincipalClass</key>
|
<key>NSPrincipalClass</key>
|
||||||
<string>NSApplication</string>
|
<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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
+44
-4
@@ -17,6 +17,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.2"
|
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:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -597,6 +629,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.3"
|
version: "6.3.3"
|
||||||
|
gtk:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: gtk
|
||||||
|
sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
hooks:
|
hooks:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -785,10 +825,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.17.0"
|
||||||
mobile_scanner:
|
mobile_scanner:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1174,10 +1214,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.10"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ dependencies:
|
|||||||
path_provider: ^2.1.4
|
path_provider: ^2.1.4
|
||||||
open_filex: ^4.5.0
|
open_filex: ^4.5.0
|
||||||
url_launcher: ^6.3.1
|
url_launcher: ^6.3.1
|
||||||
|
app_links: ^6.3.0
|
||||||
video_player: ^2.9.2
|
video_player: ^2.9.2
|
||||||
firebase_core: ^4.1.1
|
firebase_core: ^4.1.1
|
||||||
firebase_messaging: ^16.0.2
|
firebase_messaging: ^16.0.2
|
||||||
|
|||||||
Reference in New Issue
Block a user