Update Qlyra application
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s

This commit is contained in:
sevenhill
2026-08-30 22:53:15 +03:00
parent 116d3fb1d3
commit 50816588e4
4812 changed files with 14559 additions and 403 deletions
@@ -0,0 +1,31 @@
part of '../chat_info_screen.dart';
class _MemberInfo {
final int id;
final String? name;
final String? avatarUrl;
final bool isAdmin;
final bool isOwner;
final bool isMe;
final String? alias;
final int? seenTime;
final int presenceStatus;
final bool blocked;
final bool isContact;
const _MemberInfo({
required this.id,
this.name,
this.avatarUrl,
required this.isAdmin,
required this.isOwner,
required this.isMe,
this.alias,
this.seenTime,
required this.presenceStatus,
this.blocked = false,
this.isContact = false,
});
bool get isOnline => presenceStatus == 1;
}
@@ -0,0 +1,58 @@
part of '../chat_list_screen.dart';
class _StoriesScrollPhysics extends BouncingScrollPhysics {
final bool Function() blockPositive;
final bool Function() allowPullOverscrollTop;
const _StoriesScrollPhysics({
required this.blockPositive,
required this.allowPullOverscrollTop,
super.parent,
});
@override
_StoriesScrollPhysics applyTo(ScrollPhysics? ancestor) {
return _StoriesScrollPhysics(
blockPositive: blockPositive,
allowPullOverscrollTop: allowPullOverscrollTop,
parent: buildParent(ancestor),
);
}
@override
double applyBoundaryConditions(ScrollMetrics position, double value) {
if (blockPositive() && value > 0.0) {
return value - max(0.0, position.pixels);
}
if (!allowPullOverscrollTop() &&
value < position.minScrollExtent &&
position.pixels <= position.minScrollExtent) {
return value - position.minScrollExtent;
}
return super.applyBoundaryConditions(position, value);
}
}
class ForwardTarget {
final int chatId;
final String name;
final String imageUrl;
final String chatType;
const ForwardTarget({
required this.chatId,
required this.name,
required this.imageUrl,
required this.chatType,
});
}
Future<ForwardTarget?> openForwardScreen({
required BuildContext context,
int messageCount = 1,
}) {
return pushSwipeable<ForwardTarget>(
context,
(_) => ChatListScreen(forwardMode: true, forwardMessageCount: messageCount),
);
}
@@ -0,0 +1,127 @@
part of '../chat_screen.dart';
class _DateSeparatorItem {
final DateTime date;
final GlobalKey key;
_DateSeparatorItem(this.date, this.key);
}
class _MessageItem {
final CachedMessage message;
final int index;
const _MessageItem(this.message, this.index);
}
class _UnreadSeparatorItem {
const _UnreadSeparatorItem();
}
class _FrostedPanel extends StatelessWidget {
final Color tint;
final Border? border;
final double sigma;
final BackdropKey? backdropKey;
final Widget child;
const _FrostedPanel({
required this.tint,
this.border,
this.sigma = AppFrost.panelSigma,
this.backdropKey,
required this.child,
});
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.passthrough,
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: GlassSurface(
frostTint: tint,
frostSigma: sigma,
border: border,
backdropKey: backdropKey,
child: const SizedBox.expand(),
),
),
child,
],
);
}
}
class _MeasureSize extends StatefulWidget {
final Widget child;
final ValueChanged<double> onHeight;
const _MeasureSize({required this.onHeight, required this.child});
@override
State<_MeasureSize> createState() => _MeasureSizeState();
}
class _MeasureSizeState extends State<_MeasureSize> {
final GlobalKey _key = GlobalKey();
double _last = -1;
void _report() {
if (!mounted) return;
final height = _key.currentContext?.size?.height;
if (height == null) return;
if ((height - _last).abs() > 0.5) {
_last = height;
widget.onHeight(height);
}
}
void _scheduleReport() {
WidgetsBinding.instance.addPostFrameCallback((_) => _report());
}
@override
Widget build(BuildContext context) {
_scheduleReport();
return NotificationListener<SizeChangedLayoutNotification>(
onNotification: (_) {
_scheduleReport();
return true;
},
child: SizeChangedLayoutNotifier(
child: SizedBox(key: _key, child: widget.child),
),
);
}
}
class ForwardRequest {
final int sourceChatId;
final String sourceChatName;
final String sourceChatIconUrl;
final String sourceChatType;
final List<CachedMessage> messages;
ForwardRequest({
required this.sourceChatId,
required this.sourceChatName,
required this.sourceChatIconUrl,
required this.sourceChatType,
required List<CachedMessage> messages,
}) : messages = List.unmodifiable(messages);
ForwardRequest withMessages(List<CachedMessage> value) => ForwardRequest(
sourceChatId: sourceChatId,
sourceChatName: sourceChatName,
sourceChatIconUrl: sourceChatIconUrl,
sourceChatType: sourceChatType,
messages: value,
);
}
class ReplyRequest {
final int sourceChatId;
final CachedMessage message;
const ReplyRequest({required this.sourceChatId, required this.message});
}
@@ -0,0 +1,52 @@
import 'package:geolocator/geolocator.dart';
enum LocationAttachmentFailure {
serviceDisabled,
permissionDenied,
unavailable,
}
class LocationAttachmentResult {
final Position? position;
final LocationAttachmentFailure? failure;
const LocationAttachmentResult._({this.position, this.failure});
const LocationAttachmentResult.success(Position value)
: this._(position: value);
const LocationAttachmentResult.failed(LocationAttachmentFailure value)
: this._(failure: value);
}
class LocationAttachmentController {
Future<LocationAttachmentResult> resolveCurrentPosition() async {
try {
if (!await Geolocator.isLocationServiceEnabled()) {
return const LocationAttachmentResult.failed(
LocationAttachmentFailure.serviceDisabled,
);
}
var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
return const LocationAttachmentResult.failed(
LocationAttachmentFailure.permissionDenied,
);
}
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
);
return LocationAttachmentResult.success(position);
} catch (_) {
return const LocationAttachmentResult.failed(
LocationAttachmentFailure.unavailable,
);
}
}
}
@@ -53,35 +53,7 @@ import 'group_invite_sheets.dart';
import 'profile_action_sheets.dart';
import '../../../core/config/app_fonts.dart';
class _MemberInfo {
final int id;
final String? name;
final String? avatarUrl;
final bool isAdmin;
final bool isOwner;
final bool isMe;
final String? alias;
final int? seenTime;
final int presenceStatus;
final bool blocked;
final bool isContact;
const _MemberInfo({
required this.id,
this.name,
this.avatarUrl,
required this.isAdmin,
required this.isOwner,
required this.isMe,
this.alias,
this.seenTime,
required this.presenceStatus,
this.blocked = false,
this.isContact = false,
});
bool get isOnline => presenceStatus == 1;
}
part 'chat/chat_info_support.dart';
enum ChatInfoTab { media }
@@ -100,65 +100,10 @@ import '../downloads_screen.dart';
import '../../widgets/media_playback_pill.dart';
import '../../../core/config/app_fonts.dart';
part 'chat/chat_list_support.dart';
const String _savedWelcomeKey = 'welcome.saved.dialog.message';
class _StoriesScrollPhysics extends BouncingScrollPhysics {
final bool Function() blockPositive;
final bool Function() allowPullOverscrollTop;
const _StoriesScrollPhysics({
required this.blockPositive,
required this.allowPullOverscrollTop,
super.parent,
});
@override
_StoriesScrollPhysics applyTo(ScrollPhysics? ancestor) {
return _StoriesScrollPhysics(
blockPositive: blockPositive,
allowPullOverscrollTop: allowPullOverscrollTop,
parent: buildParent(ancestor),
);
}
@override
double applyBoundaryConditions(ScrollMetrics position, double value) {
if (blockPositive() && value > 0.0) {
return value - max(0.0, position.pixels);
}
if (!allowPullOverscrollTop() &&
value < position.minScrollExtent &&
position.pixels <= position.minScrollExtent) {
return value - position.minScrollExtent;
}
return super.applyBoundaryConditions(position, value);
}
}
class ForwardTarget {
final int chatId;
final String name;
final String imageUrl;
final String chatType;
const ForwardTarget({
required this.chatId,
required this.name,
required this.imageUrl,
required this.chatType,
});
}
Future<ForwardTarget?> openForwardScreen({
required BuildContext context,
int messageCount = 1,
}) {
return pushSwipeable<ForwardTarget>(
context,
(_) => ChatListScreen(forwardMode: true, forwardMessageCount: messageCount),
);
}
class ChatListScreen extends StatefulWidget {
final ValueChanged<DesktopChatSelection>? onChatSelected;
final bool forwardMode;
+17 -156
View File
@@ -4,7 +4,6 @@ import 'dart:io' show File;
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:file_picker/file_picker.dart';
import 'package:geolocator/geolocator.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
@@ -119,137 +118,14 @@ import 'scheduled_messages_screen.dart';
import 'chat_encryption_screen.dart';
import 'chat_wallpaper_preview_screen.dart';
import 'chat/retain_offset_physics.dart';
import 'chat/location_attachment_controller.dart';
import 'profile_action_sheets.dart';
import '../../../core/media/media_playback.dart';
import '../../widgets/media_playback_pill.dart';
import '../../../core/config/app_fonts.dart';
import '../../../core/config/app_shape.dart';
class _DateSeparatorItem {
final DateTime date;
final GlobalKey key;
_DateSeparatorItem(this.date, this.key);
}
class _MessageItem {
final CachedMessage message;
final int index;
const _MessageItem(this.message, this.index);
}
class _UnreadSeparatorItem {
const _UnreadSeparatorItem();
}
class _FrostedPanel extends StatelessWidget {
final Color tint;
final Border? border;
final double sigma;
final BackdropKey? backdropKey;
final Widget child;
const _FrostedPanel({
required this.tint,
this.border,
this.sigma = AppFrost.panelSigma,
this.backdropKey,
required this.child,
});
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.passthrough,
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: GlassSurface(
frostTint: tint,
frostSigma: sigma,
border: border,
backdropKey: backdropKey,
child: const SizedBox.expand(),
),
),
child,
],
);
}
}
class _MeasureSize extends StatefulWidget {
final Widget child;
final ValueChanged<double> onHeight;
const _MeasureSize({required this.onHeight, required this.child});
@override
State<_MeasureSize> createState() => _MeasureSizeState();
}
class _MeasureSizeState extends State<_MeasureSize> {
final GlobalKey _key = GlobalKey();
double _last = -1;
void _report() {
if (!mounted) return;
final height = _key.currentContext?.size?.height;
if (height == null) return;
if ((height - _last).abs() > 0.5) {
_last = height;
widget.onHeight(height);
}
}
void _scheduleReport() {
WidgetsBinding.instance.addPostFrameCallback((_) => _report());
}
@override
Widget build(BuildContext context) {
_scheduleReport();
return NotificationListener<SizeChangedLayoutNotification>(
onNotification: (_) {
_scheduleReport();
return true;
},
child: SizeChangedLayoutNotifier(
child: SizedBox(key: _key, child: widget.child),
),
);
}
}
class ForwardRequest {
final int sourceChatId;
final String sourceChatName;
final String sourceChatIconUrl;
final String sourceChatType;
final List<CachedMessage> messages;
ForwardRequest({
required this.sourceChatId,
required this.sourceChatName,
required this.sourceChatIconUrl,
required this.sourceChatType,
required List<CachedMessage> messages,
}) : messages = List.unmodifiable(messages);
ForwardRequest withMessages(List<CachedMessage> value) => ForwardRequest(
sourceChatId: sourceChatId,
sourceChatName: sourceChatName,
sourceChatIconUrl: sourceChatIconUrl,
sourceChatType: sourceChatType,
messages: value,
);
}
class ReplyRequest {
final int sourceChatId;
final CachedMessage message;
const ReplyRequest({required this.sourceChatId, required this.message});
}
part 'chat/chat_screen_support.dart';
class ChatScreen extends StatefulWidget {
final int chatId;
@@ -562,6 +438,8 @@ class _ChatScreenState extends State<ChatScreen>
bool _subscribing = false;
String? _channelLink;
final ChatController _chatController = ChatController();
final LocationAttachmentController _locationAttachment =
LocationAttachmentController();
List<CachedMessage> get _messages => _chatController.messages;
set _messages(List<CachedMessage> v) => _chatController.messages = v;
@@ -6682,8 +6560,19 @@ class _ChatScreenState extends State<ChatScreen>
}
Future<void> _shareLocation() async {
final position = await _resolveCurrentPosition();
if (position == null || !mounted) return;
final result = await _locationAttachment.resolveCurrentPosition();
if (!mounted) return;
final position = result.position;
if (position == null) {
final message = switch (result.failure) {
LocationAttachmentFailure.serviceDisabled => 'Включите геолокацию',
LocationAttachmentFailure.permissionDenied =>
'Нет доступа к геолокации',
_ => 'Не удалось получить геопозицию',
};
showCustomNotification(context, message);
return;
}
final lat = position.latitude;
final lon = position.longitude;
await _sendAttachMessage([
@@ -6691,34 +6580,6 @@ class _ChatScreenState extends State<ChatScreen>
], () => messagesModule.sendLocationMessage(widget.chatId, lat, lon));
}
Future<Position?> _resolveCurrentPosition() async {
try {
if (!await Geolocator.isLocationServiceEnabled()) {
if (mounted) showCustomNotification(context, 'Включите геолокацию');
return null;
}
var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
if (mounted)
showCustomNotification(context, 'Нет доступа к геолокации');
return null;
}
return await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
);
} catch (e) {
if (mounted)
showCustomNotification(context, 'Не удалось получить геопозицию');
return null;
}
}
Future<void> _sendContact(CachedContact contact) async {
final last = contact.lastName;
final fullName = (last != null && last.isNotEmpty)
@@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
@@ -51,20 +50,6 @@ class DigitalIdWebScreen extends StatelessWidget {
logger.i('[DID] loadStart: ${url.scheme}://${url.host}${url.path}');
}
},
shouldOverrideUrlLoading: (_, action, _) async {
final uri = action.request.url;
final url = uri?.toString() ?? '';
final scheme = uri?.scheme ?? '';
if (kDebugMode) {
debugPrint(
'[QLYRA-DID] nav: ${url.length > 140 ? url.substring(0, 140) : url}',
);
}
if (scheme != 'http' && scheme != 'https') {
return NavigationActionPolicy.CANCEL;
}
return NavigationActionPolicy.ALLOW;
},
);
}
}
@@ -1,17 +1,17 @@
import 'dart:io';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform;
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/utils/format.dart';
import '../../../core/utils/ip_lookup_service.dart';
import '../../../core/config/app_colors.dart';
import '../../../l10n/app_localizations.dart';
import '../../../main.dart' show accountModule;
import '../../../backend/modules/account.dart' show SessionInfo;
import '../../widgets/custom_notification.dart';
import '../../widgets/connection_status.dart';
import '../../widgets/confirm_dialog.dart';
import '../../widgets/reload_on_reconnect.dart';
import '../../widgets/glossy_pill.dart';
import '../../widgets/prompt_dialog.dart';
@@ -31,7 +31,7 @@ class _DevicesScreenState extends State<DevicesScreen>
with SingleTickerProviderStateMixin, ReloadOnReconnect {
bool _isLoading = true;
List<SessionInfo> _sessions = [];
final Map<int, Map<String, dynamic>> _ipDetails = {};
final Map<int, IpLookupDetails> _ipDetails = {};
final Set<int> _loadingIps = {};
final Set<int> _expandedSessions = {};
late AnimationController _shimmerController;
@@ -139,30 +139,29 @@ class _DevicesScreenState extends State<DevicesScreen>
if (match == null) return;
final ip = match.group(0)!;
if (mounted) {
setState(() => _loadingIps.add(id));
}
final l10n = AppLocalizations.of(context)!;
final confirmed = await showConfirmDialog(
context,
title: l10n.devicesIpLookupConfirmTitle,
message: l10n.devicesIpLookupConfirmMessage(
ip,
IpLookupService.providerName,
),
confirmLabel: l10n.devicesIpLookupConfirmAction,
cancelLabel: l10n.devicesIpLookupCancelAction,
);
if (!confirmed || !mounted) return;
setState(() => _loadingIps.add(id));
HttpClient? client;
try {
client = HttpClient();
client.connectionTimeout = const Duration(seconds: 5);
final request = await client.getUrl(
Uri.parse(
'http://ip-api.com/json/$ip?fields=status,message,country,city,isp,as,mobile,proxy,timezone',
),
);
final response = await request.close();
if (response.statusCode == 200) {
final body = await response.transform(utf8.decoder).join();
final data = jsonDecode(body);
if (mounted) {
setState(() {
_ipDetails[id] = data;
_expandedSessions.add(id);
_loadingIps.remove(id);
});
}
final details = await IpLookupService.lookup(ip);
if (mounted) {
setState(() {
_ipDetails[id] = details;
_expandedSessions.add(id);
_loadingIps.remove(id);
});
}
} catch (e) {
if (mounted) {
@@ -172,8 +171,6 @@ class _DevicesScreenState extends State<DevicesScreen>
AppLocalizations.of(context)!.devicesIpLookupError(e.toString()),
);
}
} finally {
client?.close();
}
}
@@ -579,26 +576,22 @@ class _DevicesScreenState extends State<DevicesScreen>
_buildDetailRow(
cs,
Symbols.location_city,
'${details['city'] ?? 'Unknown'}, ${details['country'] ?? ''}',
),
_buildDetailRow(
cs,
Symbols.dns,
details['isp'] ?? 'Unknown',
'${details.city}, ${details.country}',
),
_buildDetailRow(cs, Symbols.dns, details.isp),
_buildDetailRow(
cs,
Symbols.public,
details['as'] ?? 'Unknown',
details.network,
),
if (details['mobile'] == true)
if (details.mobile)
_buildDetailRow(
cs,
Symbols.stay_current_portrait,
l10n.devicesMobileNetworkLabel,
color: Colors.blueAccent,
),
if (details['proxy'] == true)
if (details.proxy)
_buildDetailRow(
cs,
Symbols.vpn_lock,
@@ -608,7 +601,7 @@ class _DevicesScreenState extends State<DevicesScreen>
_buildDetailRow(
cs,
Symbols.schedule,
details['timezone'] ?? 'Unknown',
details.timezone,
),
],
),
@@ -5,6 +5,8 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../../core/push/fkm_bridge.dart';
import '../../../core/push/fkm_controller.dart';
import '../../../core/push/push_service.dart';
import '../../../core/calls/call_bridge.dart';
import '../../../core/utils/haptics.dart';
import '../../../l10n/app_localizations.dart';
import '../../../main.dart' show accountModule, isOnemeFlavor;
@@ -90,6 +92,45 @@ class _NotificationsScreenState extends State<NotificationsScreen>
if (mounted) setState(() => _hapticsEnabled = value);
}
Future<void> _onAllNotificationsChanged(bool value) async {
if (value && isOnemeFlavor) {
final granted = await PushService.instance.requestPermissionFromUser();
if (!mounted) return;
if (!granted) {
showCustomNotification(
context,
AppLocalizations.of(context)!.notificationsFkmPermissionDenied,
);
return;
}
}
await _apply(
value,
() => accountModule.setChatsPushNotification(value),
(enabled) => _allNotifications = enabled,
);
}
Future<void> _onCallNotificationsChanged(bool value) async {
if (value && !await CallBridge.instance.canUseFullScreenIntent()) {
if (!mounted) return;
final l10n = AppLocalizations.of(context)!;
final confirmed = await showConfirmDialog(
context,
title: l10n.notificationsCallPermissionTitle,
message: l10n.notificationsCallPermissionMessage,
confirmLabel: l10n.notificationsCallPermissionAction,
);
if (!confirmed) return;
await CallBridge.instance.openFullScreenIntentSettings();
}
await _apply(
value,
() => accountModule.setCallNotifications(value),
(enabled) => _callNotifications = enabled,
);
}
void _openWebPush() {
Navigator.of(
context,
@@ -211,11 +252,7 @@ class _NotificationsScreenState extends State<NotificationsScreen>
icon: Symbols.notifications,
label: l10n.notificationsAllLabel,
value: _allNotifications,
onChanged: (v) => _apply(
v,
() => accountModule.setChatsPushNotification(v),
(b) => _allNotifications = b,
),
onChanged: _onAllNotificationsChanged,
),
],
),
@@ -263,11 +300,7 @@ class _NotificationsScreenState extends State<NotificationsScreen>
icon: Symbols.call,
label: l10n.notificationsCallsLabel,
value: _callNotifications,
onChanged: (v) => _apply(
v,
() => accountModule.setCallNotifications(v),
(b) => _callNotifications = b,
),
onChanged: _onCallNotificationsChanged,
),
SettingsToggleTile(
icon: Symbols.person_add,
@@ -36,11 +36,16 @@ typedef WebAppEmitter =
const Duration _gestureWindow = Duration(milliseconds: 3000);
const Set<String> _gestureGated = {
'WebAppRequestPhone',
'WebAppMaxShare',
'WebAppShare',
'WebAppDownloadFile',
'WebAppOpenLink',
'WebAppOpenMaxLink',
'WebAppBiometryRequestAccess',
'WebAppBiometryRequestAuth',
'WebAppBiometryUpdateToken',
'WebAppOpenCodeReader',
};
const Map<String, String> _methodSlugs = {
@@ -596,6 +601,23 @@ class WebAppBridge {
}
Future<void> _biometryAuth(String method, String? requestId) async {
final context = contextResolver();
if (context == null) {
_fail(method, requestId, 'access_denied');
return;
}
final confirmed = await showConfirmDialog(
context,
title: 'Разрешить биометрический доступ?',
message:
'Мини-приложение сможет создать и использовать локальный токен доступа.',
confirmLabel: 'Разрешить',
cancelLabel: 'Отклонить',
);
if (!confirmed) {
_fail(method, requestId, 'access_denied');
return;
}
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) {
_fail(method, requestId, 'access_denied');
@@ -631,6 +653,11 @@ class WebAppBridge {
_fail(method, requestId, 'access_denied');
return;
}
final access = await WebAppStorage.biometryAccess(accountId, botId);
if (!access.$2) {
_fail(method, requestId, 'access_denied');
return;
}
final token = data['token']?.toString();
if (token == null || token.isEmpty) {
await WebAppStorage.removeBiometryToken(accountId, botId);
@@ -677,10 +704,26 @@ class WebAppBridge {
_fail(method, requestId, 'invalid_request');
return;
}
final context = contextResolver();
if (context == null) {
_fail(method, requestId, 'not_supported');
return;
}
final rawName = data['file_name']?.toString();
final name = (rawName == null || rawName.isEmpty)
? 'webapp_${DateTime.now().millisecondsSinceEpoch}'
: rawName;
final confirmed = await showConfirmDialog(
context,
title: 'Скачать файл?',
message: 'Мини-приложение хочет сохранить файл «$name» на устройстве.',
confirmLabel: 'Скачать',
cancelLabel: 'Отмена',
);
if (!confirmed) {
_fail(method, requestId, 'user_declined');
return;
}
final result = await saveMediaFile(
cacheName: 'webapp_${botId}_${url.hashCode & 0x7fffffff}_$name',
resolveUrl: () async => url,
@@ -14,6 +14,7 @@ import '../../widgets/error_view.dart';
import '../../widgets/small_spinner.dart';
import '../../widgets/webview_permission_prompt.dart';
import 'web_app_bridge.dart';
import 'web_app_security_policy.dart';
class WebAppScreen extends StatefulWidget {
final String title;
@@ -33,6 +34,7 @@ class WebAppScreen extends StatefulWidget {
final Future<WebAppLaunch> Function(String url)? onExternalCallback;
final bool closeAfterExternalCallback;
final bool preferSystemUserAgent;
final List<String> allowedOrigins;
final Future<NavigationActionPolicy?> Function(
InAppWebViewController controller,
NavigationAction navigationAction,
@@ -54,6 +56,7 @@ class WebAppScreen extends StatefulWidget {
this.onExternalCallback,
this.closeAfterExternalCallback = false,
this.preferSystemUserAgent = false,
this.allowedOrigins = const [],
this.shouldOverrideUrlLoading,
});
@@ -65,6 +68,8 @@ class _WebAppScreenState extends State<WebAppScreen> {
InAppWebViewController? _controller;
WebAppBridge? _bridge;
WebAppLaunch? _launch;
WebAppSecurityPolicy? _securityPolicy;
Uri? _currentUrl;
String? _loadError;
String _userAgent = '';
double _progress = 0;
@@ -86,6 +91,8 @@ class _WebAppScreenState extends State<WebAppScreen> {
setState(() {
_loadError = null;
_launch = null;
_securityPolicy = null;
_currentUrl = null;
_bridge?.dispose();
_bridge = null;
});
@@ -107,9 +114,15 @@ class _WebAppScreenState extends State<WebAppScreen> {
'';
}
final launch = await widget.loader();
final policy = WebAppSecurityPolicy.fromLaunchUrl(
launch.url,
additionalOrigins: widget.allowedOrigins,
);
if (!mounted) return;
setState(() {
_launch = launch;
_securityPolicy = policy;
_currentUrl = Uri.parse(launch.url);
_bridge = _createBridge(launch.botId);
});
} catch (e) {
@@ -161,7 +174,10 @@ class _WebAppScreenState extends State<WebAppScreen> {
) async {
final uri = action.request.url;
final callback = widget.onExternalCallback;
if (callback != null && uri?.queryParameters['externalCallback'] == '1') {
if (callback != null &&
uri != null &&
WebAppSecurityPolicy.originOf(uri) != null &&
uri.queryParameters['externalCallback'] == '1') {
try {
final launch = await callback(uri.toString());
if (!mounted) return NavigationActionPolicy.CANCEL;
@@ -171,6 +187,11 @@ class _WebAppScreenState extends State<WebAppScreen> {
}
setState(() {
_launch = launch;
_securityPolicy = WebAppSecurityPolicy.fromLaunchUrl(
launch.url,
additionalOrigins: widget.allowedOrigins,
);
_currentUrl = Uri.parse(launch.url);
_loadError = null;
});
await controller.loadUrl(
@@ -182,12 +203,20 @@ class _WebAppScreenState extends State<WebAppScreen> {
return NavigationActionPolicy.CANCEL;
}
final handler = widget.shouldOverrideUrlLoading;
if (handler != null) return handler(controller, action, _launch?.url);
if (handler != null) {
final decision = await handler(controller, action, _launch?.url);
if (decision == NavigationActionPolicy.CANCEL) return decision;
}
if (uri != null && leavesWebView(uri.scheme)) {
if (mounted) await openExternalUrl(context, uri.toString());
return NavigationActionPolicy.CANCEL;
}
final policy = _securityPolicy;
if (uri != null && policy != null && !policy.allowsNavigation(uri)) {
if (mounted) await openExternalUrl(context, uri.toString());
return NavigationActionPolicy.CANCEL;
}
return NavigationActionPolicy.ALLOW;
}
@@ -241,7 +270,7 @@ class _WebAppScreenState extends State<WebAppScreen> {
}
final launch = _launch;
final bridge = _bridge;
if (launch == null || bridge == null) {
if (launch == null || bridge == null || _securityPolicy == null) {
return const Center(child: SmallSpinner(size: 36));
}
return LayoutBuilder(
@@ -265,12 +294,13 @@ class _WebAppScreenState extends State<WebAppScreen> {
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
domStorageEnabled: true,
thirdPartyCookiesEnabled: true,
thirdPartyCookiesEnabled: false,
supportZoom: false,
transparentBackground: true,
mediaPlaybackRequiresUserGesture: false,
mediaPlaybackRequiresUserGesture: true,
allowsInlineMediaPlayback: true,
sharedCookiesEnabled: true,
sharedCookiesEnabled: false,
incognito: true,
allowsBackForwardNavigationGestures: true,
useHybridComposition: true,
supportMultipleWindows: true,
@@ -283,8 +313,12 @@ class _WebAppScreenState extends State<WebAppScreen> {
bridge.attach(controller);
widget.onWebViewCreated?.call(controller);
},
onPermissionRequest: (controller, request) =>
askWebViewPermission(context, request),
onPermissionRequest: (controller, request) => askWebViewPermission(
context,
request,
policy: _securityPolicy!,
currentUrl: _currentUrl,
),
onCreateWindow: (controller, action) async {
final url = action.request.url?.toString();
if (url != null && url.isNotEmpty && mounted) {
@@ -293,7 +327,10 @@ class _WebAppScreenState extends State<WebAppScreen> {
return false;
},
onConsoleMessage: widget.onConsoleMessage,
onLoadStart: widget.onLoadStart,
onLoadStart: (controller, url) {
_currentUrl = url == null ? null : Uri.parse(url.toString());
widget.onLoadStart?.call(controller, url);
},
shouldOverrideUrlLoading: _handleNavigation,
onProgressChanged: (controller, progress) {
if (!mounted) return;
@@ -0,0 +1,44 @@
class WebAppSecurityPolicy {
WebAppSecurityPolicy._(this._allowedOrigins);
final Set<String> _allowedOrigins;
factory WebAppSecurityPolicy.fromLaunchUrl(
String launchUrl, {
Iterable<String> additionalOrigins = const [],
}) {
final launchOrigin = originOf(Uri.parse(launchUrl));
if (launchOrigin == null) {
throw const FormatException('WebApp launch URL must use HTTPS');
}
final origins = <String>{launchOrigin};
for (final raw in additionalOrigins) {
final origin = originOf(Uri.parse(raw));
if (origin == null) {
throw FormatException('WebApp allowlist origin must use HTTPS: $raw');
}
origins.add(origin);
}
return WebAppSecurityPolicy._(Set.unmodifiable(origins));
}
Set<String> get allowedOrigins => _allowedOrigins;
bool allowsNavigation(Uri uri) {
if (uri.scheme == 'about' && uri.toString() == 'about:blank') return true;
final origin = originOf(uri);
return origin != null && _allowedOrigins.contains(origin);
}
bool allowsPermission(Uri origin, Uri? currentUrl) {
if (!allowsNavigation(origin)) return false;
return currentUrl == null || allowsNavigation(currentUrl);
}
static String? originOf(Uri uri) {
if (uri.scheme.toLowerCase() != 'https' || uri.host.isEmpty) return null;
final host = uri.host.toLowerCase();
final port = uri.hasPort && uri.port != 443 ? ':${uri.port}' : '';
return 'https://$host$port';
}
}