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
+1 -2
View File
@@ -216,8 +216,7 @@ class DeepLinkService {
bool _isLogExportLink(Uri uri) {
final scheme = uri.scheme.toLowerCase();
final segments = <String>[
if (scheme == 'qlyra' && uri.host.isNotEmpty)
uri.host,
if (scheme == 'qlyra' && uri.host.isNotEmpty) uri.host,
...uri.pathSegments,
].where((s) => s.isNotEmpty).toList();
+23 -6
View File
@@ -210,7 +210,6 @@ class PushService {
await initLocalNotificationActions();
final messaging = FirebaseMessaging.instance;
await messaging.requestPermission();
messaging.onTokenRefresh.listen((t) async {
_token = t;
@@ -220,8 +219,27 @@ class PushService {
final prefs = await SharedPreferences.getInstance();
_token = prefs.getString(_prefsTokenKey);
final settings = await messaging.getNotificationSettings();
if (!_isAuthorized(settings.authorizationStatus)) return;
await _refreshToken();
}
Future<bool> requestPermissionFromUser() async {
if (!_initialized) return false;
final settings = await FirebaseMessaging.instance.requestPermission();
if (!_isAuthorized(settings.authorizationStatus)) return false;
await _refreshToken();
await _registerWithServer();
return true;
}
bool _isAuthorized(AuthorizationStatus status) =>
status == AuthorizationStatus.authorized ||
status == AuthorizationStatus.provisional;
Future<void> _refreshToken() async {
try {
_token = await messaging.getToken() ?? _token;
_token = await FirebaseMessaging.instance.getToken() ?? _token;
if (_token != null) await _persistToken(_token!);
logger.i('Push: FCM-токен получен (${_token?.length ?? 0} симв.)');
} catch (e) {
@@ -232,10 +250,9 @@ class PushService {
Future<void> onLoginSuccess() async {
if (!_initialized) return;
if (_token == null) {
try {
_token = await FirebaseMessaging.instance.getToken();
if (_token != null) await _persistToken(_token!);
} catch (_) {}
final settings = await FirebaseMessaging.instance
.getNotificationSettings();
if (_isAuthorized(settings.authorizationStatus)) await _refreshToken();
}
await _registerWithServer();
}
+82
View File
@@ -0,0 +1,82 @@
import 'dart:convert';
import 'dart:io';
class IpLookupDetails {
final String city;
final String country;
final String isp;
final String network;
final bool mobile;
final bool proxy;
final String timezone;
const IpLookupDetails({
required this.city,
required this.country,
required this.isp,
required this.network,
required this.mobile,
required this.proxy,
required this.timezone,
});
factory IpLookupDetails.fromIpWhoIs(Map<String, dynamic> data) {
if (data['success'] != true) {
throw FormatException(data['message']?.toString() ?? 'IP lookup failed');
}
final connection = data['connection'] is Map
? (data['connection'] as Map).cast<String, dynamic>()
: const <String, dynamic>{};
final security = data['security'] is Map
? (data['security'] as Map).cast<String, dynamic>()
: const <String, dynamic>{};
final timezoneData = data['timezone'] is Map
? (data['timezone'] as Map).cast<String, dynamic>()
: const <String, dynamic>{};
final asn = connection['asn'];
final organization = connection['org']?.toString() ?? '';
final network = [
if (asn != null) 'AS$asn',
if (organization.isNotEmpty) organization,
].join(' ');
return IpLookupDetails(
city: data['city']?.toString() ?? '',
country: data['country']?.toString() ?? '',
isp: connection['isp']?.toString() ?? organization,
network: network,
mobile: security['mobile'] == true,
proxy: security['proxy'] == true,
timezone: timezoneData['id']?.toString() ?? '',
);
}
}
abstract class IpLookupService {
static const String providerName = 'ipwho.is';
static const Duration _timeout = Duration(seconds: 8);
static Future<IpLookupDetails> lookup(String ip) async {
final uri = Uri.https(providerName, '/$ip');
final client = HttpClient()..connectionTimeout = _timeout;
try {
final request = await client.getUrl(uri);
request.headers.set(HttpHeaders.userAgentHeader, 'QlyraIpLookup');
final response = await request.close().timeout(_timeout);
if (response.statusCode != HttpStatus.ok) {
await response.drain<void>();
throw HttpException('HTTP ${response.statusCode}', uri: uri);
}
final body = await response
.transform(const Utf8Decoder())
.join()
.timeout(_timeout);
final decoded = jsonDecode(body);
if (decoded is! Map) {
throw const FormatException('Invalid IP lookup response');
}
return IpLookupDetails.fromIpWhoIs(decoded.cast<String, dynamic>());
} finally {
client.close(force: true);
}
}
}
+57 -28
View File
@@ -10,7 +10,7 @@ class AppUpdateInfo {
final String tag;
final String url;
final String notes;
final Map<String, String> assets;
final List<AppUpdateArtifact> artifacts;
const AppUpdateInfo({
required this.version,
@@ -18,7 +18,25 @@ class AppUpdateInfo {
required this.tag,
required this.url,
required this.notes,
required this.assets,
required this.artifacts,
});
}
class AppUpdateArtifact {
final String name;
final String url;
final String flavor;
final String abi;
final int sizeBytes;
final String sha256;
const AppUpdateArtifact({
required this.name,
required this.url,
required this.flavor,
required this.abi,
required this.sizeBytes,
required this.sha256,
});
}
@@ -41,8 +59,8 @@ class UpdateCheckResult {
abstract class UpdateChecker {
static const String _userAgent = 'QlyraUpdateChecker';
static const String _baseUrl = 'https://argus.kusoft.xyz';
static const String _manifestUrl =
'$_baseUrl/api/apps/qlyra/manifest?platform=android&channel=stable';
static String _manifestUrl(String slug) =>
'$_baseUrl/api/apps/$slug/manifest?platform=android&channel=stable';
static const String _lastCheckKey = 'update_last_check_ms';
static const String _skippedTagKey = 'update_skipped_tag';
@@ -53,8 +71,9 @@ abstract class UpdateChecker {
final info = await PackageInfo.fromPlatform();
final currentBase = info.version;
final currentBuild = _normalizeBuild(int.tryParse(info.buildNumber));
final flavor = manifestSlugForPackageName(info.packageName);
final release = await _fetchLatestRelease();
final release = await _fetchLatestRelease(flavor);
if (release == null) return null;
final tag = (release['tag_name'] as String?)?.trim();
@@ -76,9 +95,9 @@ abstract class UpdateChecker {
version: remoteBase,
build: remoteBuild,
tag: tag,
url: (release['html_url'] as String?) ?? _releasesPage,
url: (release['html_url'] as String?) ?? _manifestUrl(flavor),
notes: (release['body'] as String?)?.trim() ?? '',
assets: _parseAssets(release['assets']),
artifacts: (release['artifacts'] as List).cast<AppUpdateArtifact>(),
);
}
@@ -133,10 +152,13 @@ abstract class UpdateChecker {
await prefs.setString(_skippedTagKey, tag);
}
static String get _releasesPage => _manifestUrl;
static String manifestSlugForPackageName(String packageName) =>
packageName == 'ru.oneme.app' ? 'oneme' : 'qlyra';
static Future<Map<String, dynamic>?> _fetchLatestRelease() async {
final uri = Uri.parse(_manifestUrl);
static Future<Map<String, dynamic>?> _fetchLatestRelease(
String flavor,
) async {
final uri = Uri.parse(_manifestUrl(flavor));
final client = HttpClient()..connectionTimeout = _timeout;
try {
final req = await client.getUrl(uri);
@@ -144,6 +166,10 @@ abstract class UpdateChecker {
..set(HttpHeaders.userAgentHeader, _userAgent)
..set(HttpHeaders.acceptHeader, 'application/json');
final resp = await req.close().timeout(_timeout);
if (resp.statusCode == HttpStatus.notFound) {
await resp.drain<void>();
return null;
}
if (resp.statusCode != HttpStatus.ok) {
await resp.drain<void>();
throw HttpException('Argus returned HTTP ${resp.statusCode}', uri: uri);
@@ -159,17 +185,32 @@ abstract class UpdateChecker {
final release = (decoded['release'] as Map).cast<String, dynamic>();
final version = release['version'] as String?;
final downloadPath = release['downloadPath'] as String?;
if (version == null || downloadPath == null) return null;
final originalFileName = release['originalFileName'] as String?;
final packageSizeBytes = release['packageSizeBytes'] as int?;
final digest = (release['sha256'] as String?)?.toLowerCase();
if (version == null ||
downloadPath == null ||
originalFileName == null ||
packageSizeBytes == null ||
packageSizeBytes <= 0 ||
digest == null ||
!RegExp(r'^[0-9a-f]{64}$').hasMatch(digest)) {
throw const FormatException('Incomplete Argus release metadata');
}
final downloadUrl = Uri.parse(_baseUrl).resolve(downloadPath).toString();
return {
'tag_name': version,
'html_url': downloadUrl,
'body': release['notes'] as String? ?? '',
'assets': [
{
'name': 'qlyra-qlyra-universal.apk',
'browser_download_url': downloadUrl,
},
'artifacts': [
AppUpdateArtifact(
name: originalFileName,
url: downloadUrl,
flavor: flavor,
abi: 'universal',
sizeBytes: packageSizeBytes,
sha256: digest,
),
],
};
} finally {
@@ -177,18 +218,6 @@ abstract class UpdateChecker {
}
}
static Map<String, String> _parseAssets(dynamic raw) {
final result = <String, String>{};
if (raw is! List) return result;
for (final entry in raw) {
if (entry is! Map) continue;
final name = entry['name'] as String?;
final url = entry['browser_download_url'] as String?;
if (name != null && url != null) result[name] = url;
}
return result;
}
static String _baseVersion(String tag) {
var s = tag.trim();
if (s.startsWith('v') || s.startsWith('V')) s = s.substring(1);
+111 -29
View File
@@ -1,13 +1,21 @@
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/services.dart';
import 'package:open_filex/open_filex.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'update_checker.dart';
enum UpdateInstallStatus { done, noAsset, downloadFailed, installFailed }
enum UpdateInstallStatus {
done,
noAsset,
downloadFailed,
integrityFailed,
installFailed,
}
class UpdateInstallResult {
final UpdateInstallStatus status;
@@ -19,20 +27,29 @@ class UpdateInstallResult {
}
abstract class UpdateInstaller {
static const MethodChannel _integrityChannel = MethodChannel(
'ru.qlyra.app/update_integrity',
);
static bool get isSupported => Platform.isAndroid;
static Future<UpdateInstallResult> downloadAndInstall(
AppUpdateInfo info, {
void Function(double progress)? onProgress,
}) async {
final url = await resolveApkUrl(info);
if (url == null) {
final artifact = await resolveApkArtifact(info);
if (artifact == null) {
return const UpdateInstallResult(UpdateInstallStatus.noAsset);
}
File file;
try {
file = await _download(url, info.tag, onProgress);
file = await _download(artifact, info.tag, onProgress);
} on UpdateIntegrityException catch (e) {
return UpdateInstallResult(
UpdateInstallStatus.integrityFailed,
error: e.message,
);
} catch (e) {
return UpdateInstallResult(
UpdateInstallStatus.downloadFailed,
@@ -40,6 +57,15 @@ abstract class UpdateInstaller {
);
}
final signerMatches = await _verifySigner(file.path);
if (!signerMatches) {
await file.delete();
return const UpdateInstallResult(
UpdateInstallStatus.integrityFailed,
error: 'APK signer does not match the installed application',
);
}
final opened = await OpenFilex.open(
file.path,
type: 'application/vnd.android.package-archive',
@@ -53,33 +79,40 @@ abstract class UpdateInstaller {
return const UpdateInstallResult(UpdateInstallStatus.done);
}
static Future<String?> resolveApkUrl(AppUpdateInfo info) async {
if (!Platform.isAndroid || info.assets.isEmpty) return null;
static Future<AppUpdateArtifact?> resolveApkArtifact(
AppUpdateInfo info,
) async {
if (!Platform.isAndroid || info.artifacts.isEmpty) return null;
final packageInfo = await PackageInfo.fromPlatform();
final flavor = packageInfo.packageName == 'ru.oneme.app'
? 'oneme'
: 'qlyra';
final androidInfo = await DeviceInfoPlugin().androidInfo;
final abis = androidInfo.supportedAbis;
for (final abi in abis) {
final url = _findAsset(info.assets, '-$flavor-$abi.apk');
if (url != null) return url;
}
return _findAsset(info.assets, '-$flavor-universal.apk');
return selectArtifact(
info.artifacts,
packageInfo.packageName,
androidInfo.supportedAbis,
);
}
static String? _findAsset(Map<String, String> assets, String suffix) {
for (final entry in assets.entries) {
if (entry.key.endsWith(suffix)) return entry.value;
static AppUpdateArtifact? selectArtifact(
List<AppUpdateArtifact> artifacts,
String packageName,
List<String> supportedAbis,
) {
final flavor = UpdateChecker.manifestSlugForPackageName(packageName);
final matching = artifacts.where((item) => item.flavor == flavor).toList();
for (final abi in supportedAbis) {
for (final artifact in matching) {
if (artifact.abi == abi) return artifact;
}
}
for (final artifact in matching) {
if (artifact.abi == 'universal') return artifact;
}
return null;
}
static Future<File> _download(
String url,
AppUpdateArtifact artifact,
String tag,
void Function(double progress)? onProgress,
) async {
@@ -90,25 +123,40 @@ abstract class UpdateInstaller {
final client = HttpClient();
try {
final request = await client.getUrl(Uri.parse(url));
final uri = Uri.parse(artifact.url);
final request = await client.getUrl(uri);
request.headers.set(HttpHeaders.userAgentHeader, 'QlyraUpdateInstaller');
final response = await request.close();
if (response.statusCode != HttpStatus.ok) {
await response.drain<void>();
throw HttpException('HTTP ${response.statusCode}', uri: Uri.parse(url));
throw HttpException('HTTP ${response.statusCode}', uri: uri);
}
final total = response.contentLength;
if (total >= 0 && total != artifact.sizeBytes) {
await response.drain<void>();
throw UpdateIntegrityException('Unexpected Content-Length: $total');
}
var received = 0;
final sink = part.openWrite();
await for (final chunk in response) {
received += chunk.length;
sink.add(chunk);
if (onProgress != null && total > 0) {
onProgress(received / total);
try {
await for (final chunk in response) {
received += chunk.length;
if (received > artifact.sizeBytes) {
throw const UpdateIntegrityException('APK is larger than expected');
}
sink.add(chunk);
if (onProgress != null) {
onProgress(received / artifact.sizeBytes);
}
}
} finally {
await sink.close();
}
await sink.close();
if (received != artifact.sizeBytes) {
throw UpdateIntegrityException('Unexpected APK size: $received bytes');
}
await verifyArtifact(part, artifact);
if (await file.exists()) await file.delete();
await part.rename(file.path);
return file;
@@ -123,4 +171,38 @@ abstract class UpdateInstaller {
client.close();
}
}
static Future<bool> _verifySigner(String path) async {
try {
return await _integrityChannel.invokeMethod<bool>('verifyApkSigner', {
'path': path,
}) ??
false;
} on PlatformException {
return false;
}
}
static Future<void> verifyArtifact(
File file,
AppUpdateArtifact artifact,
) async {
final size = await file.length();
if (size != artifact.sizeBytes) {
throw UpdateIntegrityException('Unexpected APK size: $size bytes');
}
final digest = await sha256.bind(file.openRead()).first;
if (digest.toString().toLowerCase() != artifact.sha256.toLowerCase()) {
throw const UpdateIntegrityException('APK SHA-256 mismatch');
}
}
}
class UpdateIntegrityException implements Exception {
final String message;
const UpdateIntegrityException(this.message);
@override
String toString() => message;
}
@@ -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';
}
}
+6 -2
View File
@@ -140,8 +140,12 @@ class _UpdateProgressDialogState extends State<_UpdateProgressDialog> {
Navigator.pop(context);
if (!result.ok) {
final l10n = AppLocalizations.of(context)!;
showCustomNotification(context, l10n.updateDownloadFailed);
await openExternalUrl(context, widget.info.url);
if (result.status == UpdateInstallStatus.integrityFailed) {
showCustomNotification(context, l10n.updateIntegrityFailed);
} else {
showCustomNotification(context, l10n.updateDownloadFailed);
await openExternalUrl(context, widget.info.url);
}
}
}
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../../core/config/app_shape.dart';
import '../screens/webapp/web_app_security_policy.dart';
String _resourceLabel(PermissionResourceType type) {
if (type == PermissionResourceType.CAMERA) return 'камера';
@@ -14,14 +15,22 @@ String _resourceLabel(PermissionResourceType type) {
Future<PermissionResponse> askWebViewPermission(
BuildContext context,
PermissionRequest request,
) async {
PermissionRequest request, {
required WebAppSecurityPolicy policy,
required Uri? currentUrl,
}) async {
PermissionResponse deny() => PermissionResponse(
resources: request.resources,
action: PermissionResponseAction.DENY,
);
if (!context.mounted) return deny();
if (!context.mounted ||
!policy.allowsPermission(
Uri.parse(request.origin.toString()),
currentUrl,
)) {
return deny();
}
final labels = <String>{
for (final r in request.resources) _resourceLabel(r),
+14
View File
@@ -264,6 +264,9 @@
"notificationsFkmBatteryMessage": "Otherwise the system will put the background connection to sleep and notifications will be late or lost.",
"notificationsFkmBatteryTitle": "Turn off battery saving?",
"notificationsFkmPermissionDenied": "FKM cannot work without the notification permission",
"notificationsCallPermissionTitle": "Allow full-screen incoming calls?",
"notificationsCallPermissionMessage": "Android needs separate permission to show an incoming call over the lock screen. The system settings will open only after you confirm.",
"notificationsCallPermissionAction": "Open settings",
"notificationsFkmConfirmAction": "Enable FKM",
"notificationsFkmConfirmMessage": "Notifications will arrive over the apps own background connection, and a permanent service notification will stay in the shade. You can turn FKM off right from it.",
"notificationsMainSectionTitle": "Notifications",
@@ -304,6 +307,17 @@
}
}
},
"devicesIpLookupConfirmTitle": "Look up this IP address?",
"devicesIpLookupConfirmMessage": "IP address {ip} will be sent to the third-party service {provider} to determine its approximate location and network.",
"@devicesIpLookupConfirmMessage": {
"placeholders": {
"ip": { "type": "String" },
"provider": { "type": "String" }
}
},
"devicesIpLookupConfirmAction": "Continue",
"devicesIpLookupCancelAction": "Cancel",
"updateIntegrityFailed": "Update rejected: APK size, SHA-256, package ID, or signature verification failed",
"devicesTitle": "Devices",
"devicesPromoTitle": "Devices in QLYRA",
"devicesPromoSubtitle": "Who has access to your account?",
+48
View File
@@ -1472,6 +1472,24 @@ abstract class AppLocalizations {
/// **'FKM cannot work without the notification permission'**
String get notificationsFkmPermissionDenied;
/// No description provided for @notificationsCallPermissionTitle.
///
/// In en, this message translates to:
/// **'Allow full-screen incoming calls?'**
String get notificationsCallPermissionTitle;
/// No description provided for @notificationsCallPermissionMessage.
///
/// In en, this message translates to:
/// **'Android needs separate permission to show an incoming call over the lock screen. The system settings will open only after you confirm.'**
String get notificationsCallPermissionMessage;
/// No description provided for @notificationsCallPermissionAction.
///
/// In en, this message translates to:
/// **'Open settings'**
String get notificationsCallPermissionAction;
/// No description provided for @notificationsFkmConfirmAction.
///
/// In en, this message translates to:
@@ -1586,6 +1604,36 @@ abstract class AppLocalizations {
/// **'IP error: {error}'**
String devicesIpLookupError(String error);
/// No description provided for @devicesIpLookupConfirmTitle.
///
/// In en, this message translates to:
/// **'Look up this IP address?'**
String get devicesIpLookupConfirmTitle;
/// No description provided for @devicesIpLookupConfirmMessage.
///
/// In en, this message translates to:
/// **'IP address {ip} will be sent to the third-party service {provider} to determine its approximate location and network.'**
String devicesIpLookupConfirmMessage(String ip, String provider);
/// No description provided for @devicesIpLookupConfirmAction.
///
/// In en, this message translates to:
/// **'Continue'**
String get devicesIpLookupConfirmAction;
/// No description provided for @devicesIpLookupCancelAction.
///
/// In en, this message translates to:
/// **'Cancel'**
String get devicesIpLookupCancelAction;
/// No description provided for @updateIntegrityFailed.
///
/// In en, this message translates to:
/// **'Update rejected: APK size, SHA-256, package ID, or signature verification failed'**
String get updateIntegrityFailed;
/// No description provided for @devicesTitle.
///
/// In en, this message translates to:
+29
View File
@@ -730,6 +730,17 @@ class AppLocalizationsEn extends AppLocalizations {
String get notificationsFkmPermissionDenied =>
'FKM cannot work without the notification permission';
@override
String get notificationsCallPermissionTitle =>
'Allow full-screen incoming calls?';
@override
String get notificationsCallPermissionMessage =>
'Android needs separate permission to show an incoming call over the lock screen. The system settings will open only after you confirm.';
@override
String get notificationsCallPermissionAction => 'Open settings';
@override
String get notificationsFkmConfirmAction => 'Enable FKM';
@@ -795,6 +806,24 @@ class AppLocalizationsEn extends AppLocalizations {
return 'IP error: $error';
}
@override
String get devicesIpLookupConfirmTitle => 'Look up this IP address?';
@override
String devicesIpLookupConfirmMessage(String ip, String provider) {
return 'IP address $ip will be sent to the third-party service $provider to determine its approximate location and network.';
}
@override
String get devicesIpLookupConfirmAction => 'Continue';
@override
String get devicesIpLookupCancelAction => 'Cancel';
@override
String get updateIntegrityFailed =>
'Update rejected: APK size, SHA-256, package ID, or signature verification failed';
@override
String get devicesTitle => 'Devices';
+29
View File
@@ -733,6 +733,17 @@ class AppLocalizationsRu extends AppLocalizations {
String get notificationsFkmPermissionDenied =>
'Без разрешения на уведомления FKM не заработает';
@override
String get notificationsCallPermissionTitle =>
'Разрешить полноэкранные входящие звонки?';
@override
String get notificationsCallPermissionMessage =>
'Android требует отдельное разрешение для показа входящего звонка поверх экрана блокировки. Системные настройки откроются только после подтверждения.';
@override
String get notificationsCallPermissionAction => 'Открыть настройки';
@override
String get notificationsFkmConfirmAction => 'Включить FKM';
@@ -798,6 +809,24 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Ошибка IP: $error';
}
@override
String get devicesIpLookupConfirmTitle => 'Проверить IP-адрес?';
@override
String devicesIpLookupConfirmMessage(String ip, String provider) {
return 'IP-адрес $ip будет передан стороннему сервису $provider для определения примерного местоположения и сети.';
}
@override
String get devicesIpLookupConfirmAction => 'Продолжить';
@override
String get devicesIpLookupCancelAction => 'Отмена';
@override
String get updateIntegrityFailed =>
'Обновление отклонено: размер, SHA-256, package ID или подпись APK не прошли проверку';
@override
String get devicesTitle => 'Устройства';
+14
View File
@@ -243,6 +243,9 @@
"notificationsFkmBatteryMessage": "Иначе система усыпит фоновое соединение, и уведомления начнут опаздывать или пропадать.",
"notificationsFkmBatteryTitle": "Отключить экономию батареи?",
"notificationsFkmPermissionDenied": "Без разрешения на уведомления FKM не заработает",
"notificationsCallPermissionTitle": "Разрешить полноэкранные входящие звонки?",
"notificationsCallPermissionMessage": "Android требует отдельное разрешение для показа входящего звонка поверх экрана блокировки. Системные настройки откроются только после подтверждения.",
"notificationsCallPermissionAction": "Открыть настройки",
"notificationsFkmConfirmAction": "Включить FKM",
"notificationsFkmConfirmMessage": "Уведомления начнут приходить через собственное фоновое соединение, а в шторке будет постоянно висеть уведомление сервиса. Выключить FKM можно прямо в нём.",
"notificationsMainSectionTitle": "Уведомления",
@@ -262,6 +265,17 @@
"devicesAllTerminated": "Все сессии завершены",
"devicesGenericError": "Ошибка: {error}",
"devicesIpLookupError": "Ошибка IP: {error}",
"devicesIpLookupConfirmTitle": "Проверить IP-адрес?",
"devicesIpLookupConfirmMessage": "IP-адрес {ip} будет передан стороннему сервису {provider} для определения примерного местоположения и сети.",
"@devicesIpLookupConfirmMessage": {
"placeholders": {
"ip": { "type": "String" },
"provider": { "type": "String" }
}
},
"devicesIpLookupConfirmAction": "Продолжить",
"devicesIpLookupCancelAction": "Отмена",
"updateIntegrityFailed": "Обновление отклонено: размер, SHA-256, package ID или подпись APK не прошли проверку",
"devicesTitle": "Устройства",
"devicesPromoTitle": "Устройства в Qlyra",
"devicesPromoSubtitle": "Кто имеет доступ к вашему аккаунту?",
-9
View File
@@ -439,7 +439,6 @@ class QlyraAppState extends State<QlyraApp>
if (isOnemeFlavor) {
await PushService.instance.init(api: api, account: accountModule);
await PushService.instance.onLoginSuccess();
await _ensureFullScreenIntentPermission();
}
}
});
@@ -534,14 +533,6 @@ class QlyraAppState extends State<QlyraApp>
});
}
Future<void> _ensureFullScreenIntentPermission() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool('fsi_prompted') ?? false) return;
if (await CallBridge.instance.canUseFullScreenIntent()) return;
await prefs.setBool('fsi_prompted', true);
await CallBridge.instance.openFullScreenIntentSettings();
}
void _onIncomingCall(IncomingCall call) {
_pendingIncoming = call;
_presentIncomingCall();