242 lines
5.7 KiB
Dart
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;
|
|
}
|
|
}
|