From f7265b4fe8d27489fe0575c8992c5c77a5295726 Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 24 Jul 2026 14:36:18 +0300 Subject: [PATCH] =?UTF-8?q?fix(digital-id):=20=D0=B7=D0=B0=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=88=D0=B0=D1=82=D1=8C=20=D0=95=D0=A1=D0=98=D0=90=20cal?= =?UTF-8?q?lback=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20EXTERNAL=5FCALLBACK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/webapp.dart | 55 +++++++++++++++++++ .../screens/digital_id/digital_id_screen.dart | 6 +- .../digital_id/digital_id_web_screen.dart | 17 +----- .../screens/webapp/web_app_screen.dart | 52 +++++++++++++++--- test/webapp_module_test.dart | 34 ++++++++++++ 5 files changed, 141 insertions(+), 23 deletions(-) create mode 100644 test/webapp_module_test.dart diff --git a/lib/backend/modules/webapp.dart b/lib/backend/modules/webapp.dart index 1189f82..4b078c7 100644 --- a/lib/backend/modules/webapp.dart +++ b/lib/backend/modules/webapp.dart @@ -19,6 +19,41 @@ class WebAppLaunch { const WebAppLaunch({required this.url}); } +class ExternalCallbackResult { + final int botId; + final String? startParam; + + const ExternalCallbackResult({required this.botId, this.startParam}); + + static ExternalCallbackResult? fromPayload(dynamic payload) { + final data = _findResponseMap(payload); + if (data == null) return null; + final botId = _asInt(data['botId'] ?? data['bot_id']); + if (botId == null) return null; + final startParam = data['startParam'] ?? data['start_param']; + return ExternalCallbackResult( + botId: botId, + startParam: startParam?.toString(), + ); + } + + static Map? _findResponseMap(dynamic value) { + if (value is! Map) return null; + if (value.containsKey('botId') || value.containsKey('bot_id')) return value; + for (final key in const ['data', 'result', 'response']) { + final nested = _findResponseMap(value[key]); + if (nested != null) return nested; + } + return null; + } + + static int? _asInt(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value?.toString() ?? ''); + } +} + class WebAppModule { final Api _api; @@ -68,6 +103,26 @@ class WebAppModule { return fetchLaunch(botId); } + Future handleExternalCallback(String url) async { + final uri = Uri.tryParse(url); + if (uri == null || uri.queryParameters['externalCallback'] != '1') { + throw const WebAppUnavailable('Некорректный callback Цифрового ID'); + } + if (_api.state != SessionState.online) { + throw const WebAppUnavailable('Нет соединения с сервером'); + } + final packet = await _api.sendRequest(Opcode.externalCallback, { + 'url': url, + }); + final result = ExternalCallbackResult.fromPayload(packet.payload); + if (result == null) { + throw const WebAppUnavailable( + 'Сервер не вернул данные для завершения Цифрового ID', + ); + } + return fetchLaunch(result.botId, startParam: result.startParam); + } + Future _resolveEntryApp(String key) async { final accountId = await TokenStorage.getActiveAccountId(); if (accountId == null) return null; diff --git a/lib/frontend/screens/digital_id/digital_id_screen.dart b/lib/frontend/screens/digital_id/digital_id_screen.dart index e5390f9..8bb1fa6 100644 --- a/lib/frontend/screens/digital_id/digital_id_screen.dart +++ b/lib/frontend/screens/digital_id/digital_id_screen.dart @@ -7,7 +7,7 @@ import '../../../backend/modules/digital_id.dart'; import '../../../backend/modules/webapp.dart'; import '../../../core/utils/webview_support.dart'; import '../../../l10n/app_localizations.dart'; -import '../../../main.dart' show digitalIdModule; +import '../../../main.dart' show digitalIdModule, webAppModule; import '../../../models/digital_id.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; @@ -123,10 +123,14 @@ class _DigitalIdScreenState extends State { builder: (context) => WebAppScreen( title: AppLocalizations.of(context)!.digitalIdGosuslugiTitle, loader: () async => WebAppLaunch(url: link.url), + onExternalCallback: webAppModule.handleExternalCallback, + closeAfterExternalCallback: true, ), ), ); if (!mounted) return; + await digitalIdModule.loadDocuments(createIfMissing: true); + if (!mounted) return; await _load(); } on DigitalIdException catch (e) { if (mounted) showCustomNotification(context, e.message); diff --git a/lib/frontend/screens/digital_id/digital_id_web_screen.dart b/lib/frontend/screens/digital_id/digital_id_web_screen.dart index 7a4e326..0bc22fe 100644 --- a/lib/frontend/screens/digital_id/digital_id_web_screen.dart +++ b/lib/frontend/screens/digital_id/digital_id_web_screen.dart @@ -186,6 +186,7 @@ class DigitalIdWebScreen extends StatelessWidget { }, ); }, + onExternalCallback: webAppModule.handleExternalCallback, onConsoleMessage: kDebugMode ? (controller, consoleMessage) { debugPrint('[KOMET-DID] ${consoleMessage.message}'); @@ -199,7 +200,7 @@ class DigitalIdWebScreen extends StatelessWidget { ); } : null, - shouldOverrideUrlLoading: (controller, action, currentUrl) async { + shouldOverrideUrlLoading: (_, action, _) async { final uri = action.request.url; final url = uri?.toString() ?? ''; final scheme = uri?.scheme ?? ''; @@ -208,19 +209,7 @@ class DigitalIdWebScreen extends StatelessWidget { '[KOMET-DID] nav: ${url.length > 140 ? url.substring(0, 140) : url}', ); } - final isCallback = - url.contains('?externalCallback=') || - url.contains('&externalCallback='); - if (isCallback || (scheme != 'http' && scheme != 'https')) { - final launchUrl = currentUrl ?? 'https://digital-id.max.ru'; - final hashIdx = launchUrl.indexOf('#'); - final base = hashIdx >= 0 - ? launchUrl.substring(0, hashIdx) - : launchUrl; - final frag = hashIdx >= 0 ? launchUrl.substring(hashIdx) : ''; - final query = uri?.query ?? ''; - final target = query.isEmpty ? launchUrl : '$base?$query$frag'; - controller.loadUrl(urlRequest: URLRequest(url: WebUri(target))); + if (scheme != 'http' && scheme != 'https') { return NavigationActionPolicy.CANCEL; } return NavigationActionPolicy.ALLOW; diff --git a/lib/frontend/screens/webapp/web_app_screen.dart b/lib/frontend/screens/webapp/web_app_screen.dart index e850b6d..fd2bf29 100644 --- a/lib/frontend/screens/webapp/web_app_screen.dart +++ b/lib/frontend/screens/webapp/web_app_screen.dart @@ -24,6 +24,8 @@ class WebAppScreen extends StatefulWidget { onConsoleMessage; final void Function(InAppWebViewController controller, WebUri? url)? onLoadStart; + final Future Function(String url)? onExternalCallback; + final bool closeAfterExternalCallback; final Future Function( InAppWebViewController controller, NavigationAction navigationAction, @@ -39,6 +41,8 @@ class WebAppScreen extends StatefulWidget { this.onWebViewCreated, this.onConsoleMessage, this.onLoadStart, + this.onExternalCallback, + this.closeAfterExternalCallback = false, this.shouldOverrideUrlLoading, }); @@ -67,7 +71,8 @@ class _WebAppScreenState extends State { try { // Тот же UA, что уходит в sessionInit (из handshake-устройства ядра), // чтобы веб-аппы видели нативный клиент; фолбэк — браузерный UA спуфа. - _userAgent = api.session?.userAgent() ?? + _userAgent = + api.session?.userAgent() ?? await SpoofingService.getWebViewUserAgent() ?? ''; final launch = await widget.loader(); @@ -88,6 +93,37 @@ class _WebAppScreenState extends State { return true; } + Future _handleNavigation( + InAppWebViewController controller, + NavigationAction action, + ) async { + final uri = action.request.url; + final callback = widget.onExternalCallback; + if (callback != null && uri?.queryParameters['externalCallback'] == '1') { + try { + final launch = await callback(uri.toString()); + if (!mounted) return NavigationActionPolicy.CANCEL; + if (widget.closeAfterExternalCallback) { + Navigator.of(context).pop(launch); + return NavigationActionPolicy.CANCEL; + } + setState(() { + _launch = launch; + _loadError = null; + }); + await controller.loadUrl( + urlRequest: URLRequest(url: WebUri(launch.url)), + ); + } catch (e) { + if (mounted) setState(() => _loadError = e.toString()); + } + return NavigationActionPolicy.CANCEL; + } + final handler = widget.shouldOverrideUrlLoading; + if (handler == null) return NavigationActionPolicy.ALLOW; + return handler(controller, action, _launch?.url); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -153,7 +189,9 @@ class _WebAppScreenState extends State { transparentBackground: true, mediaPlaybackRequiresUserGesture: false, useHybridComposition: true, - useShouldOverrideUrlLoading: widget.shouldOverrideUrlLoading != null, + useShouldOverrideUrlLoading: + widget.shouldOverrideUrlLoading != null || + widget.onExternalCallback != null, userAgent: _userAgent, ), onWebViewCreated: (controller) { @@ -164,13 +202,11 @@ class _WebAppScreenState extends State { askWebViewPermission(context, request), onConsoleMessage: widget.onConsoleMessage, onLoadStart: widget.onLoadStart, - shouldOverrideUrlLoading: widget.shouldOverrideUrlLoading == null + shouldOverrideUrlLoading: + widget.shouldOverrideUrlLoading == null && + widget.onExternalCallback == null ? null - : (controller, action) => widget.shouldOverrideUrlLoading!( - controller, - action, - launch.url, - ), + : _handleNavigation, onProgressChanged: (controller, progress) { if (!mounted) return; setState(() => _progress = progress / 100); diff --git a/test/webapp_module_test.dart b/test/webapp_module_test.dart new file mode 100644 index 0000000..38fcd4a --- /dev/null +++ b/test/webapp_module_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/webapp.dart'; + +void main() { + group('ExternalCallbackResult', () { + test('parses callback response fields', () { + final result = ExternalCallbackResult.fromPayload({ + 'botId': '123456', + 'startParam': 'esia-complete', + }); + + expect(result?.botId, 123456); + expect(result?.startParam, 'esia-complete'); + }); + + test('parses a nested protocol response', () { + final result = ExternalCallbackResult.fromPayload({ + 'data': {'bot_id': 42, 'start_param': 'done'}, + }); + + expect(result?.botId, 42); + expect(result?.startParam, 'done'); + }); + + test('rejects a response without a bot id', () { + expect( + ExternalCallbackResult.fromPayload({ + 'data': {'startParam': 'done'}, + }), + isNull, + ); + }); + }); +}