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
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:
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user