import 'dart:async'; import 'dart:convert'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:please_pay_me/data/models/json.dart'; import 'package:please_pay_me/theme/theme.dart'; import 'package:please_pay_me/ui/ui.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter_android/webview_flutter_android.dart'; /// Name of the JavaScript channel injected into the cabinet page. /// Must stay in sync with `web/src/auth/telegramRedirect.ts`. const telegramAuthChannel = 'PpmAuth'; /// Chrome-like UA without the `; wv` token Android WebView inserts. /// Telegram's widget rejects the default WebView user-agent on some devices. const telegramWebViewUserAgent = 'Mozilla/5.0 (Linux; Android 13; Mobile) AppleWebKit/537.36 ' '(KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'; /// Runs inside the cabinet and hands the JWT back to Flutter. /// /// Two paths, both without backend changes: /// 1. The web app posts to `PpmAuth` from `setSession` right after login. /// 2. This script hooks `localStorage.setItem` and polls, so an already-open /// session (or an older cabinet build) still works. const telegramTokenProbeJs = ''' (function () { function ping() { try { var token = window.localStorage.getItem('ppm_session_jwt'); if (token && window.$telegramAuthChannel) { $telegramAuthChannel.postMessage(JSON.stringify({ token: token })); } } catch (error) {} } if (!window.__ppmAuthHooked) { window.__ppmAuthHooked = true; try { var original = window.localStorage.setItem.bind(window.localStorage); window.localStorage.setItem = function (key, value) { original(key, value); if (key === 'ppm_session_jwt') ping(); }; } catch (error) {} } ping(); })(); '''; /// WebView login only exists on mobile; desktop and web fall back to the token /// form. bool get isTelegramWebLoginSupported { if (kIsWeb) return false; return defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS; } /// The cabinet and the API share an origin (nginx proxies `/api`), so the API /// address can be derived unless it was configured explicitly. String resolveApiBaseUrl({ required String cabinetUrl, String configuredApiBaseUrl = '', }) { if (configuredApiBaseUrl.trim().isNotEmpty) { return configuredApiBaseUrl.trim().replaceAll(RegExp(r'/$'), ''); } return resolveCabinetOrigin(cabinetUrl); } /// Origin of the cabinet URL — used both as the API base and as a sanity check. String resolveCabinetOrigin(String cabinetUrl) { final uri = resolveCabinetLoginUri(cabinetUrl); return uri.hasPort && !_isDefaultPort(uri) ? '${uri.scheme}://${uri.host}:${uri.port}' : '${uri.scheme}://${uri.host}'; } /// Always open `/login` so the Telegram widget is on screen. Uri resolveCabinetLoginUri(String cabinetUrl) { var raw = cabinetUrl.trim(); if (raw.isEmpty) return Uri.parse('https://localhost/login'); if (!raw.contains('://')) { raw = 'https://$raw'; } final uri = Uri.parse(raw); final path = uri.path; if (path.isEmpty || path == '/') { return uri.replace(path: '/login'); } return uri; } bool _isDefaultPort(Uri uri) { return (uri.scheme == 'https' && uri.port == 443) || (uri.scheme == 'http' && uri.port == 80); } /// Extracts the JWT from the payload posted by [telegramTokenProbeJs]. String? parseTelegramAuthMessage(String raw) { try { final token = asString(asMap(jsonDecode(raw))['token']); return token.isEmpty ? null : token; } on FormatException { return null; } } bool isExternalAuthScheme(Uri uri) { return uri.scheme == 'tg' || uri.scheme == 'telegram'; } bool isTelegramOAuthHost(String host) { return host == 'oauth.telegram.org' || host == 'telegram.org' || host.endsWith('.telegram.org'); } /// Opens the cabinet in an in-app browser and resolves with the JWT once the /// user has signed in through the Telegram Login Widget. Future showTelegramLogin({ required BuildContext context, required String cabinetUrl, }) { return Navigator.of(context, rootNavigator: true).push( CupertinoPageRoute( fullscreenDialog: true, builder: (_) => TelegramLoginScreen(cabinetUrl: cabinetUrl), ), ); } class TelegramLoginScreen extends StatefulWidget { const TelegramLoginScreen({super.key, required this.cabinetUrl}); final String cabinetUrl; @override State createState() => _TelegramLoginScreenState(); } class _TelegramLoginScreenState extends State { late final WebViewController _controller; Timer? _poll; bool _loading = true; bool _completed = false; bool _canGoBack = false; String? _error; Uri get _startUri => resolveCabinetLoginUri(widget.cabinetUrl); @override void initState() { super.initState(); _controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setUserAgent(telegramWebViewUserAgent) ..setBackgroundColor(const Color(0x00000000)) ..addJavaScriptChannel( telegramAuthChannel, onMessageReceived: (message) => _onToken(message.message), ) ..setNavigationDelegate( NavigationDelegate( onNavigationRequest: _onNavigationRequest, onPageStarted: (_) { if (mounted) setState(() => _loading = true); }, onPageFinished: (_) { _refreshCanGoBack(); if (mounted) setState(() => _loading = false); _probe(); }, onWebResourceError: (error) { if (!mounted || _completed) return; // Subframe errors (the Telegram iframe) must not kill the page. if (error.isForMainFrame == false) return; setState(() { _loading = false; _error = error.description; }); }, ), ); _configureAndroid(); _controller.loadRequest(_startUri); // The widget writes the token after an async callback, so polling is more // reliable than a single probe on page load. _poll = Timer.periodic(const Duration(milliseconds: 700), (_) => _probe()); } Future _configureAndroid() async { if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return; final platform = _controller.platform; if (platform is! AndroidWebViewController) return; final cookies = AndroidWebViewCookieManager( const PlatformWebViewCookieManagerCreationParams(), ); await cookies.setAcceptThirdPartyCookies(platform, true); } @override void dispose() { _poll?.cancel(); super.dispose(); } Future _probe() async { if (_completed) return; try { await _controller.runJavaScript(telegramTokenProbeJs); } on PlatformException { // The page may be mid-navigation; the next tick retries. } } Future _onNavigationRequest(NavigationRequest request) async { final uri = Uri.tryParse(request.url); if (uri == null) return NavigationDecision.navigate; if (isExternalAuthScheme(uri)) { await launchUrl(uri, mode: LaunchMode.externalApplication); return NavigationDecision.prevent; } return NavigationDecision.navigate; } Future _refreshCanGoBack() async { final canGoBack = await _controller.canGoBack(); if (mounted && canGoBack != _canGoBack) { setState(() => _canGoBack = canGoBack); } } void _onToken(String raw) { if (_completed) return; final token = parseTelegramAuthMessage(raw); if (token == null) return; _completed = true; _poll?.cancel(); Navigator.of(context).pop(token); } @override Widget build(BuildContext context) { return CupertinoPageScaffold( backgroundColor: AppColors.of(context, AppColors.groupedBackground), navigationBar: AppNavBar( title: 'Вход через Telegram', subtitle: _startUri.host, leading: CupertinoButton( padding: EdgeInsets.zero, minimumSize: Size.zero, onPressed: () => Navigator.of(context).pop(), child: const AppText.body('Закрыть', color: AppColors.accent), ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ if (_canGoBack) CupertinoButton( padding: EdgeInsets.zero, minimumSize: Size.zero, onPressed: () async { await _controller.goBack(); await _refreshCanGoBack(); }, child: const AppIcon(CupertinoIcons.back, color: AppColors.accent), ), CupertinoButton( padding: EdgeInsets.zero, minimumSize: Size.zero, onPressed: () { setState(() => _error = null); _controller.reload(); }, child: const AppIcon(CupertinoIcons.refresh, color: AppColors.accent), ), ], ), ), child: SafeArea( child: _error != null ? AppErrorView( message: _error!, onRetry: () { setState(() => _error = null); _controller.reload(); }, ) : Stack( children: [ WebViewWidget(controller: _controller), if (_loading) const Center(child: AppSpinner()), ], ), ), ); } }