Files
vpn/lib/services/vpn_service.dart
2026-04-20 00:27:09 +03:00

242 lines
5.7 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:v2ray_box/v2ray_box.dart';
import 'package:quasar_jet/models/storage_model.dart';
final class VpnService extends ChangeNotifier {
static const MethodChannel _quickTileChannel = MethodChannel(
'vpn/quick_tile',
);
final V2rayBox _v2rayBox = V2rayBox();
StreamSubscription<VpnStatus>? _statusSubscription;
bool _isInitialized = false;
bool _isProcessing = false;
bool _isConnected = false;
String _connectionState = 'DISCONNECTED';
bool get isProcessing => _isProcessing;
bool get isConnected => _isConnected;
String get connectionState => _connectionState;
Future<String?> init() async {
if (_isInitialized) return null;
try {
await _v2rayBox.initialize(notificationStopButtonText: 'Stop');
_statusSubscription ??= _v2rayBox.watchStatus().listen(
_handleStatusChanged,
);
_isInitialized = true;
return null;
} catch (e) {
return 'Init error: $e';
}
}
Future<String?> toggle(StorageModel model) async {
if (_isProcessing) return null;
if (_isConnected) {
return stop();
}
final String link = _selectedConfig(model).trim();
if (link.isEmpty) {
return 'Config link is empty';
}
return start(link);
}
Future<String?> start(String link) async {
if (_isProcessing) return null;
_isProcessing = true;
notifyListeners();
try {
final String? initError = await init();
if (initError != null) {
return initError;
}
final String normalizedLink = link.trim();
if (normalizedLink.isEmpty) {
return 'Config link is empty';
}
final granted = await _requestVpnPermission();
if (!granted) {
return 'VPN permission denied';
}
await _v2rayBox.setServiceMode(VpnMode.vpn);
final bool started = await _v2rayBox.connect(
normalizedLink,
name: getName(normalizedLink) ?? 'VPN',
);
if (!started) {
return 'Failed to start VPN';
}
_connectionState = 'CONNECTING';
notifyListeners();
return null;
} catch (e) {
return 'Error: $e';
} finally {
_isProcessing = false;
notifyListeners();
}
}
Future<String?> stop() async {
if (_isProcessing) return null;
_isProcessing = true;
notifyListeners();
try {
await _v2rayBox.disconnect();
_connectionState = 'DISCONNECTED';
_isConnected = false;
await _syncTileConnectionState();
return null;
} catch (e) {
return 'Error: $e';
} finally {
_isProcessing = false;
notifyListeners();
}
}
VpnConfig parseConfig(String link) {
return _v2rayBox.parseConfigLink(link);
}
String _selectedConfig(StorageModel model) {
return model.resolveActiveConfigLink();
}
void _handleStatusChanged(VpnStatus status) {
switch (status) {
case VpnStatus.stopped:
_connectionState = 'DISCONNECTED';
_isConnected = false;
_isProcessing = false;
case VpnStatus.starting:
_connectionState = 'CONNECTING';
_isConnected = false;
_isProcessing = true;
case VpnStatus.started:
_connectionState = 'CONNECTED';
_isConnected = true;
_isProcessing = false;
case VpnStatus.stopping:
_connectionState = 'DISCONNECTING';
_isConnected = false;
_isProcessing = true;
}
unawaited(_syncTileConnectionState());
notifyListeners();
}
Future<bool> _requestVpnPermission() async {
final hasPermission = await _v2rayBox.checkVpnPermission();
if (hasPermission) {
return true;
}
return _v2rayBox.requestVpnPermission();
}
String? getName(String config) {
final String link = config.trim();
if (link.isEmpty) {
return null;
}
try {
final parsed = _v2rayBox.parseConfigLink(link);
final String parsedName = parsed.name.trim();
return parsedName.isEmpty ? null : parsedName;
} catch (_) {
return null;
}
}
Future<void> syncActiveConfig(StorageModel model) async {
if (!_supportsQuickTile) {
return;
}
final Map<String, dynamic>? payload = _buildActiveConfigPayload(model);
try {
if (payload == null) {
await _quickTileChannel.invokeMethod<void>('clearActiveConfig');
} else {
await _quickTileChannel.invokeMethod<void>('setActiveConfig', payload);
}
await _syncTileConnectionState();
await _quickTileChannel.invokeMethod<void>('refreshTile');
} catch (_) {
// Quick settings integration is optional; ignore platform failures.
}
}
Map<String, dynamic>? _buildActiveConfigPayload(StorageModel model) {
final String link = model.resolveActiveConfigLink().trim();
if (link.isEmpty) {
return null;
}
try {
final parsed = _v2rayBox.parseConfigLink(link);
final String remark = parsed.name.trim();
if (remark.isEmpty) {
return null;
}
return <String, dynamic>{
'remark': remark,
'config': link,
'proxyOnly': false,
};
} catch (_) {
return null;
}
}
Future<void> _syncTileConnectionState() async {
if (!_supportsQuickTile) {
return;
}
try {
await _quickTileChannel.invokeMethod<void>(
'setConnectionState',
<String, dynamic>{'connected': _isConnected},
);
} catch (_) {
// Quick settings integration is optional; ignore platform failures.
}
}
@override
void dispose() {
unawaited(_statusSubscription?.cancel());
super.dispose();
}
bool get _supportsQuickTile {
return !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
}
}