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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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 app’s 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?",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'Устройства';
|
||||
|
||||
|
||||
@@ -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": "Кто имеет доступ к вашему аккаунту?",
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user