singbox_mm 0.1.2 copy "singbox_mm: ^0.1.2" to clipboard
singbox_mm: ^0.1.2 copied to clipboard

Flutter VPN plugin with sing-box runtime bridge, routing presets, and anti-throttling config builder.

example/lib/main.dart

import 'dart:async';
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:singbox_mm/singbox_mm.dart';

void main() {
  runApp(const SignboxVpnDemoApp());
}

const String _defaultConfigLink =
    'vless://11111111-2222-3333-4444-555555555555@example.com:443?type=tcp&encryption=none&security=none#demo-node';
const String _defaultSubscription =
    'vless://11111111-2222-3333-4444-555555555555@edge-a.example.com:443?type=tcp&encryption=none&security=none#edge-a\n'
    'vless://11111111-2222-3333-4444-555555555556@edge-b.example.com:8443?type=tcp&encryption=none&security=none#edge-b';
const String _defaultPassphrase = 'sbmm-demo-passphrase';

class SignboxVpnDemoApp extends StatefulWidget {
  const SignboxVpnDemoApp({super.key});

  @override
  State<SignboxVpnDemoApp> createState() => _SignboxVpnDemoAppState();
}

class _SignboxVpnDemoAppState extends State<SignboxVpnDemoApp> {
  final SignboxVpn _vpn = SignboxVpn();
  final TextEditingController _configController = TextEditingController(
    text: _defaultConfigLink,
  );
  final TextEditingController _subscriptionController = TextEditingController(
    text: _defaultSubscription,
  );
  final TextEditingController _passphraseController = TextEditingController(
    text: _defaultPassphrase,
  );
  final TextEditingController _secureLinkController = TextEditingController();
  late final List<GfwPresetPack> _presetPacks = _vpn.listGfwPresetPacks();

  StreamSubscription<VpnConnectionState>? _stateSubscription;
  StreamSubscription<VpnConnectionSnapshot>? _stateDetailsSubscription;
  StreamSubscription<VpnRuntimeStats>? _statsSubscription;

  VpnConnectionState _state = VpnConnectionState.disconnected;
  VpnConnectionSnapshot _stateDetails = VpnConnectionSnapshot(
    state: VpnConnectionState.disconnected,
    timestamp: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
  );
  VpnRuntimeStats _stats = VpnRuntimeStats.empty();

  GfwPresetMode _selectedPresetMode = GfwPresetMode.balanced;
  bool _busy = false;
  String _message = 'Idle';
  final List<String> _activityLog = <String>[];

  Map<String, Object?>? _lastAppliedConfig;
  String? _lastAppliedConfigJson;
  VpnCoreCapabilities? _coreCapabilities;
  VpnProtocol? _currentConfigProtocol;
  bool? _currentConfigProtocolSupported;
  String? _currentConfigParseError;

  GfwPresetPack get _selectedPreset =>
      GfwPresetPack.fromMode(_selectedPresetMode);

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(_lifecycleObserver);

    _stateSubscription = _vpn.stateStream.listen((
      VpnConnectionState nextState,
    ) {
      if (!mounted) {
        return;
      }
      setState(() {
        _state = nextState;
      });
    });

    _stateDetailsSubscription = _vpn.stateDetailsStream.listen((
      VpnConnectionSnapshot details,
    ) {
      if (!mounted) {
        return;
      }
      setState(() {
        _stateDetails = details;
      });
    });

    _statsSubscription = _vpn.statsStream.listen((VpnRuntimeStats stats) {
      if (!mounted) {
        return;
      }
      setState(() {
        _stats = stats;
      });
    });

    _configController.addListener(_onConfigInputsChanged);
    _passphraseController.addListener(_onConfigInputsChanged);

    _initializeRuntime();
  }

  late final WidgetsBindingObserver _lifecycleObserver = _DemoLifecycleObserver(
    onResume: _syncRuntimeOnResume,
  );

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(_lifecycleObserver);
    _statsSubscription?.cancel();
    _stateSubscription?.cancel();
    _stateDetailsSubscription?.cancel();
    _configController.removeListener(_onConfigInputsChanged);
    _passphraseController.removeListener(_onConfigInputsChanged);
    _configController.dispose();
    _subscriptionController.dispose();
    _passphraseController.dispose();
    _secureLinkController.dispose();
    unawaited(_vpn.dispose());
    super.dispose();
  }

  Future<void> _initializeRuntime() async {
    await _runAction('Initialize Runtime', () async {
      await _vpn.initialize(
        const SingboxRuntimeOptions(
          logLevel: 'info',
          tunInterfaceName: 'sb-tun',
          tunInet4Address: '172.19.0.1/30',
          androidBinaryAssetByAbi: <String, String>{
            'arm64-v8a': 'assets/singbox/android/arm64-v8a/sing-box',
            'armeabi-v7a': 'assets/singbox/android/armeabi-v7a/sing-box',
            'x86_64': 'assets/singbox/android/x86_64/sing-box',
          },
        ),
      );
      await _ensureCoreCapabilitiesLoaded();
      await _refreshSnapshot();
      _setMessage('Runtime initialized.');
    });
  }

  void _onConfigInputsChanged() {
    _refreshConfigProtocolSupport();
  }

  Future<VpnCoreCapabilities> _ensureCoreCapabilitiesLoaded({
    bool refresh = false,
  }) async {
    if (!refresh && _coreCapabilities != null) {
      return _coreCapabilities!;
    }
    final VpnCoreCapabilities caps = await _vpn.getCoreCapabilities(
      refresh: refresh,
    );
    _coreCapabilities = caps;
    if (!mounted) {
      return caps;
    }
    setState(() {
      _coreCapabilities = caps;
    });
    _refreshConfigProtocolSupport();
    return caps;
  }

  void _refreshConfigProtocolSupport() {
    final String config = _configController.text.trim();
    VpnProtocol? protocol;
    bool? supported;
    String? parseError;

    if (config.isNotEmpty) {
      try {
        final ParsedVpnConfig parsed = _vpn.parseConfigLink(
          config,
          sbmmPassphrase: _optionalPassphrase(),
        );
        protocol = parsed.profile.protocol;
        final VpnCoreCapabilities? caps = _coreCapabilities;
        if (caps != null) {
          supported = caps.supportsProtocol(protocol);
        }
      } on Object catch (error) {
        parseError = error.toString();
      }
    }

    if (!mounted) {
      _currentConfigProtocol = protocol;
      _currentConfigProtocolSupported = supported;
      _currentConfigParseError = parseError;
      return;
    }

    setState(() {
      _currentConfigProtocol = protocol;
      _currentConfigProtocolSupported = supported;
      _currentConfigParseError = parseError;
    });
  }

  Future<void> _ensureCurrentConfigProtocolSupported() async {
    final VpnCoreCapabilities caps = await _ensureCoreCapabilitiesLoaded();
    final ParsedVpnConfig parsed = _vpn.parseConfigLink(
      _requireConfigLink(),
      sbmmPassphrase: _optionalPassphrase(),
    );
    if (caps.supportsProtocol(parsed.profile.protocol)) {
      return;
    }
    throw SignboxVpnException(
      code: 'UNSUPPORTED_PROTOCOL_FOR_CORE',
      message:
          '${parsed.profile.protocol.wireValue} is not supported by current core '
          '(${caps.displayVersion}).',
    );
  }

  Future<void> _ensureSecureConfigProtocolSupported(String secureLink) async {
    final VpnCoreCapabilities caps = await _ensureCoreCapabilitiesLoaded();
    final ParsedVpnConfig parsed = _vpn.parseConfigLink(
      secureLink,
      sbmmPassphrase: _requirePassphrase(),
    );
    if (caps.supportsProtocol(parsed.profile.protocol)) {
      return;
    }
    throw SignboxVpnException(
      code: 'UNSUPPORTED_PROTOCOL_FOR_CORE',
      message:
          '${parsed.profile.protocol.wireValue} is not supported by current core '
          '(${caps.displayVersion}).',
    );
  }

  bool _isExtremeEligibleProfile(VpnProfile profile) {
    switch (profile.protocol) {
      case VpnProtocol.vless:
        return profile.tls.enabled &&
            (profile.tls.realityPublicKey?.isNotEmpty ?? false);
      case VpnProtocol.hysteria2:
      case VpnProtocol.tuic:
        return profile.tls.enabled;
      case VpnProtocol.vmess:
      case VpnProtocol.trojan:
      case VpnProtocol.shadowsocks:
      case VpnProtocol.wireguard:
      case VpnProtocol.ssh:
        return false;
    }
  }

  Future<bool> _promptSwitchExtremeToAggressive(VpnProfile profile) async {
    if (!mounted) {
      return false;
    }
    final bool? confirmed = await showDialog<bool>(
      context: context,
      builder: (BuildContext dialogContext) {
        return AlertDialog(
          title: const Text('Extreme Preset Compatibility'),
          content: Text(
            'Current link (${profile.protocol.wireValue}) is not compatible '
            'with Extreme preset.\n\n'
            'Extreme allows only VLESS-Reality, Hysteria2, or TUIC.\n\n'
            'Switch preset to Aggressive and continue?',
          ),
          actions: <Widget>[
            TextButton(
              onPressed: () => Navigator.of(dialogContext).pop(false),
              child: const Text('Cancel'),
            ),
            FilledButton(
              onPressed: () => Navigator.of(dialogContext).pop(true),
              child: const Text('Switch to Aggressive'),
            ),
          ],
        );
      },
    );
    return confirmed ?? false;
  }

  Future<void> _ensureExtremePresetCompatibleOrOfferDowngrade() async {
    if (_selectedPresetMode != GfwPresetMode.extreme) {
      return;
    }
    final ParsedVpnConfig parsed = _vpn.parseConfigLink(
      _requireConfigLink(),
      sbmmPassphrase: _optionalPassphrase(),
    );
    if (_isExtremeEligibleProfile(parsed.profile)) {
      return;
    }
    final bool accepted = await _promptSwitchExtremeToAggressive(
      parsed.profile,
    );
    if (!accepted) {
      throw SignboxVpnException(
        code: 'EXTREME_PRESET_PROTOCOL_BLOCKED',
        message:
            'Cancelled. Extreme preset requires VLESS-Reality, Hysteria2, or TUIC.',
      );
    }
    if (!mounted) {
      return;
    }
    setState(() {
      _selectedPresetMode = GfwPresetMode.aggressive;
    });
    _appendLog(
      'Preset switch: extreme -> aggressive '
      'for ${parsed.profile.protocol.wireValue}://${parsed.profile.server}:${parsed.profile.serverPort}',
    );
    _setMessage(
      'Current link is not Extreme-compatible. Switched to Aggressive.',
    );
  }

  String _formatSignboxError(SignboxVpnException error) {
    if (error.code == 'EXTREME_PRESET_PROTOCOL_BLOCKED') {
      return '${error.code}: ${error.message} '
          '(Use VLESS-Reality/Hysteria2/TUIC for Extreme, or switch to Aggressive.)';
    }
    return '${error.code}: ${error.message}';
  }

  Future<void> _logSubscriptionCoreCompatibility(
    ParsedVpnSubscription parsed,
  ) async {
    final VpnCoreCapabilities caps = await _ensureCoreCapabilitiesLoaded();
    final Set<VpnProtocol> unsupported = parsed.profiles
        .map((VpnProfile profile) => profile.protocol)
        .where((VpnProtocol protocol) => !caps.supportsProtocol(protocol))
        .toSet();
    if (unsupported.isEmpty) {
      return;
    }
    _appendLog(
      'core unsupported in subscription (${caps.displayVersion}): '
      '${unsupported.map((VpnProtocol p) => p.wireValue).join(', ')}',
    );
  }

  Future<void> _syncRuntimeOnResume() async {
    try {
      await _vpn.syncRuntimeState();
      await _refreshSnapshot();
      _appendLog('Lifecycle resume -> runtime sync completed');
    } on Object {
      // Best effort during lifecycle resume.
    }
  }

  Future<void> _runAction(String label, Future<void> Function() action) async {
    if (_busy) {
      _setMessage('Busy, wait for current action to finish.');
      return;
    }

    setState(() {
      _busy = true;
    });
    _appendLog('$label: START');

    try {
      await action();
      _appendLog('$label: OK');
    } on SignboxVpnException catch (error) {
      _setMessage(_formatSignboxError(error));
      _appendLog('$label: FAIL ${error.code} ${error.message}');
    } on FormatException catch (error) {
      _setMessage('FORMAT_ERROR: ${error.message}');
      _appendLog('$label: FORMAT_ERROR ${error.message}');
    } on StateError catch (error) {
      _setMessage('STATE_ERROR: ${error.message}');
      _appendLog('$label: STATE_ERROR ${error.message}');
    } on Object catch (error) {
      _setMessage('UNEXPECTED: $error');
      _appendLog('$label: UNEXPECTED $error');
    } finally {
      if (mounted) {
        setState(() {
          _busy = false;
        });
      }
    }
  }

  Future<void> _refreshSnapshot() async {
    final VpnConnectionState state = await _vpn.getState();
    final VpnConnectionSnapshot details = await _vpn.getStateDetails();
    final VpnRuntimeStats stats = await _vpn.getStats();
    if (!mounted) {
      return;
    }
    setState(() {
      _state = state;
      _stateDetails = details;
      _stats = stats;
    });
  }

  String _requireConfigLink() {
    final String config = _configController.text.trim();
    if (config.isEmpty) {
      throw const FormatException('Config link is empty.');
    }
    return config;
  }

  String _requireSubscription() {
    final String raw = _subscriptionController.text.trim();
    if (raw.isEmpty) {
      throw const FormatException('Subscription text is empty.');
    }
    return raw;
  }

  String _requirePassphrase() {
    final String passphrase = _passphraseController.text.trim();
    if (passphrase.isEmpty) {
      throw const FormatException('Passphrase is empty.');
    }
    return passphrase;
  }

  String? _optionalPassphrase() {
    final String passphrase = _passphraseController.text.trim();
    if (passphrase.isEmpty) {
      return null;
    }
    return passphrase;
  }

  void _rememberAppliedConfig(
    Map<String, Object?> config, {
    required String source,
  }) {
    final Map<String, Object?> copied = (jsonDecode(jsonEncode(config)) as Map)
        .cast<String, Object?>();
    final String pretty = const JsonEncoder.withIndent('  ').convert(copied);

    _lastAppliedConfig = copied;
    _lastAppliedConfigJson = pretty;

    int outboundCount = 0;
    final Object? outbounds = copied['outbounds'];
    if (outbounds is List<dynamic>) {
      outboundCount = outbounds.length;
    }

    _appendLog('$source: remembered config (outbounds=$outboundCount)');
  }

  void _appendLog(String message) {
    final String timestamp = DateTime.now().toIso8601String();
    final String line = '[$timestamp] $message';
    debugPrint('[singbox-demo] $line');

    if (!mounted) {
      return;
    }

    setState(() {
      _activityLog.add(line);
      if (_activityLog.length > 80) {
        _activityLog.removeRange(0, _activityLog.length - 80);
      }
    });
  }

  void _setMessage(String message) {
    _appendLog('MESSAGE: $message');
    if (!mounted) {
      return;
    }
    setState(() {
      _message = message;
    });
  }

  Future<void> _pasteConfigLink() async {
    final ClipboardData? clipboard = await Clipboard.getData(
      Clipboard.kTextPlain,
    );
    final String pasted = clipboard?.text?.trim() ?? '';
    if (pasted.isEmpty) {
      _setMessage('Clipboard is empty.');
      return;
    }
    _configController.text = pasted;
    _setMessage('Config pasted from clipboard.');
  }

  Future<void> _connectBasic() async {
    await _ensureCurrentConfigProtocolSupported();
    final ManualConnectResult result = await _vpn.connectManualConfigLink(
      configLink: _requireConfigLink(),
      sbmmPassphrase: _optionalPassphrase(),
    );
    _rememberAppliedConfig(result.appliedConfig, source: 'connectBasic');
    await _refreshSnapshot();
    _setMessage('Connected (basic): ${result.profile.tag}');
  }

  Future<void> _connectHardened() async {
    await _ensureCurrentConfigProtocolSupported();
    await _ensureExtremePresetCompatibleOrOfferDowngrade();
    final ManualConnectResult result = await _vpn
        .connectManualConfigLinkWithPreset(
          configLink: _requireConfigLink(),
          sbmmPassphrase: _optionalPassphrase(),
          preset: _selectedPreset,
        );
    _rememberAppliedConfig(result.appliedConfig, source: 'connectHardened');
    await _refreshSnapshot();
    _setMessage('Connected (${_selectedPreset.name}): ${result.profile.tag}');
  }

  Future<void> _connectManualProfileDirect() async {
    await _ensureCurrentConfigProtocolSupported();
    await _ensureExtremePresetCompatibleOrOfferDowngrade();
    final ParsedVpnConfig parsed = _vpn.parseConfigLink(
      _requireConfigLink(),
      sbmmPassphrase: _optionalPassphrase(),
    );
    final ManualConnectResult result = await _vpn.connectManualProfile(
      profile: parsed.profile,
      bypassPolicy: _selectedPreset.bypassPolicy,
      throttlePolicy: _selectedPreset.throttlePolicy,
      featureSettings: _selectedPreset.featureSettings,
    );
    _rememberAppliedConfig(
      result.appliedConfig,
      source: 'connectManualProfile',
    );
    await _refreshSnapshot();
    _setMessage('Connected (manual profile): ${result.profile.tag}');
    if (result.warnings.isNotEmpty) {
      _appendLog('manual profile warnings: ${result.warnings.join(' | ')}');
    }
  }

  Future<void> _connectManualPresetDirect() async {
    await _ensureCurrentConfigProtocolSupported();
    await _ensureExtremePresetCompatibleOrOfferDowngrade();
    final ParsedVpnConfig parsed = _vpn.parseConfigLink(
      _requireConfigLink(),
      sbmmPassphrase: _optionalPassphrase(),
    );
    final ManualConnectResult result = await _vpn.connectManualWithPreset(
      profile: parsed.profile,
      preset: _selectedPreset,
    );
    _rememberAppliedConfig(result.appliedConfig, source: 'connectManualPreset');
    await _refreshSnapshot();
    _setMessage(
      'Connected (manual preset ${_selectedPreset.name}): ${result.profile.tag}',
    );
    if (result.warnings.isNotEmpty) {
      _appendLog('manual preset warnings: ${result.warnings.join(' | ')}');
    }
  }

  Future<void> _connectWithSbmmLink() async {
    String secure = _secureLinkController.text.trim();
    if (secure.isEmpty) {
      secure = _vpn.wrapSecureConfigLink(
        configLink: _requireConfigLink(),
        passphrase: _requirePassphrase(),
      );
      _secureLinkController.text = secure;
    }

    await _ensureSecureConfigProtocolSupported(secure);

    final ManualConnectResult result = await _vpn.connectManualConfigLink(
      configLink: secure,
      sbmmPassphrase: _requirePassphrase(),
    );
    _rememberAppliedConfig(result.appliedConfig, source: 'connectSbmm');
    await _refreshSnapshot();
    _setMessage('Connected (sbmm): ${result.profile.tag}');
  }

  Future<void> _disconnect() async {
    await _vpn.stop();
    await _refreshSnapshot();
    _setMessage('VPN disconnected.');
  }

  Future<void> _restartVpn() async {
    await _vpn.restart();
    await _refreshSnapshot();
    _setMessage('VPN restarted.');
  }

  Future<void> _startVpn() async {
    await _vpn.start();
    await _refreshSnapshot();
    _setMessage('VPN start requested.');
  }

  Future<void> _startManaged() async {
    await _vpn.startManaged();
    await _refreshSnapshot();
    _setMessage('Managed mode started.');
  }

  Future<void> _resetProfile() async {
    await _vpn.resetProfile(stopVpn: true);
    _lastAppliedConfig = null;
    _lastAppliedConfigJson = null;
    await _refreshSnapshot();
    _setMessage('Profile reset complete.');
  }

  Future<void> _setPresetFeatureSettings() async {
    _vpn.setFeatureSettings(_selectedPreset.featureSettings);
    _setMessage('Feature settings set from preset: ${_selectedPreset.name}');
  }

  Future<void> _requestVpnPermission() async {
    final bool granted = await _vpn.requestVpnPermission();
    _setMessage('VPN permission: ${granted ? "granted" : "denied"}');
  }

  Future<void> _requestNotificationPermission() async {
    final bool granted = await _vpn.requestNotificationPermission();
    _setMessage(
      'Notification permission: ${granted ? "granted/available" : "denied"}',
    );
  }

  Future<void> _loadVersion() async {
    final String? version = await _vpn.getSingboxVersion();
    _setMessage(version ?? 'sing-box version unavailable');
  }

  Future<void> _loadCoreCapabilities() async {
    final VpnCoreCapabilities caps = await _ensureCoreCapabilitiesLoaded(
      refresh: true,
    );
    final String supported = caps.supportedProtocols
        .map((VpnProtocol protocol) => protocol.wireValue)
        .join(', ');
    _setMessage(
      'Core ${caps.displayVersion}: ${caps.supportedProtocols.length}/${VpnProtocol.values.length} protocols supported.',
    );
    _appendLog('core supported protocols: $supported');
  }

  Future<void> _loadLastError() async {
    final String? lastError = await _vpn.getLastError();
    _setMessage(lastError ?? 'No last error');
  }

  Future<void> _syncRuntime() async {
    await _vpn.syncRuntimeState();
    await _refreshSnapshot();
    _setMessage('Runtime synchronized.');
  }

  Future<void> _parseCurrentConfig() async {
    final String config = _requireConfigLink();
    final ParsedVpnConfig parsed = _vpn.parseConfigLink(
      config,
      sbmmPassphrase: _optionalPassphrase(),
    );
    final VpnProfileSummary summary = _vpn.extractConfigLinkSummary(
      config,
      sbmmPassphrase: _optionalPassphrase(),
    );
    final VpnProfileSummary profileSummary = _vpn.summarizeProfile(
      parsed.profile,
      warnings: parsed.warnings,
    );

    _setMessage(
      'Parsed ${parsed.scheme} -> ${summary.remark} @ ${summary.endpoint}',
    );
    _appendLog('summary.transport=${profileSummary.transport.wireValue}');
    if (parsed.warnings.isNotEmpty) {
      _appendLog('parse warnings: ${parsed.warnings.join(' | ')}');
    }
  }

  Future<void> _listPresetPacksFromApi() async {
    final List<GfwPresetPack> packs = _vpn.listGfwPresetPacks();
    _setMessage('Preset packs available: ${packs.length}');
    _appendLog(
      'preset names: ${packs.map((GfwPresetPack p) => p.name).join(', ')}',
    );
  }

  Future<void> _applyProfileDirect() async {
    await _ensureCurrentConfigProtocolSupported();
    final ParsedVpnConfig parsed = _vpn.parseConfigLink(
      _requireConfigLink(),
      sbmmPassphrase: _optionalPassphrase(),
    );
    final Map<String, Object?> config = await _vpn.applyProfile(
      profile: parsed.profile,
    );
    _rememberAppliedConfig(config, source: 'applyProfileDirect');
    _setMessage('Profile applied only (not started): ${parsed.profile.tag}');
  }

  Future<void> _applyConfigLinkOnly() async {
    await _ensureCurrentConfigProtocolSupported();
    final Map<String, Object?> config = await _vpn.applyConfigLink(
      configLink: _requireConfigLink(),
      sbmmPassphrase: _optionalPassphrase(),
    );
    _rememberAppliedConfig(config, source: 'applyConfigLink');
    _setMessage('Config link applied only (not started).');
  }

  Future<void> _wrapConfigLink() async {
    final String secure = _vpn.wrapSecureConfigLink(
      configLink: _requireConfigLink(),
      passphrase: _requirePassphrase(),
    );
    _secureLinkController.text = secure;
    _setMessage('Generated sbmm secure link.');
  }

  Future<void> _unwrapSecureLink() async {
    final String secure = _secureLinkController.text.trim();
    if (secure.isEmpty) {
      throw const FormatException('Secure link is empty.');
    }
    final String raw = _vpn.unwrapSecureConfigLink(
      sbmmLink: secure,
      passphrase: _requirePassphrase(),
    );
    _configController.text = raw;
    _setMessage('Secure link unwrapped into config field.');
  }

  Future<void> _validateActiveProfile() async {
    final VpnProfile? profile = _vpn.activeProfile;
    if (profile == null) {
      throw StateError('No active profile to validate.');
    }
    final List<VpnDiagnosticIssue> issues = _vpn.validateProfile(
      profile,
      strictTls: true,
    );
    _setMessage('Validation issues: ${issues.length}');
    if (issues.isNotEmpty) {
      _appendLog(
        'issue codes: ${issues.map((VpnDiagnosticIssue i) => i.code).join(', ')}',
      );
    }
  }

  Future<void> _probeConnectivity() async {
    final VpnConnectivityProbe probe = await _vpn.probeConnectivity();
    _setMessage(
      'Probe: success=${probe.success} status=${probe.statusCode ?? '-'} latency=${probe.latencyMs ?? '-'}ms',
    );
  }

  Future<void> _runDiagnostics() async {
    final VpnDiagnosticsReport report = await _vpn.runDiagnostics(
      strictTls: true,
      includeEndpointPoolPing: true,
      includeConnectivityProbe: true,
    );
    _setMessage(
      'Diagnostics: issues=${report.issues.length}, pings=${report.pingResults.length}, probe=${report.connectivityProbe?.success ?? false}',
    );
    if (report.issues.isNotEmpty) {
      _appendLog(
        'diagnostic codes: ${report.issues.map((VpnDiagnosticIssue i) => i.code).join(', ')}',
      );
    }
  }

  Future<void> _pingActiveProfile() async {
    final VpnProfile? profile = _vpn.activeProfile;
    if (profile == null) {
      throw StateError('No active profile available to ping.');
    }
    final VpnPingResult ping = await _vpn.pingProfile(profile: profile);
    _setMessage(
      'Ping active ${profile.tag}: success=${ping.success} latency=${ping.latencyMs ?? '-'}ms via ${ping.checkMethod}',
    );
  }

  Future<void> _pingEndpointPool() async {
    final List<VpnPingResult> results = await _vpn.pingEndpointPool();
    final int ok = results.where((VpnPingResult item) => item.success).length;
    final List<VpnPingResult> successful = results
        .where((VpnPingResult item) => item.success && item.latencyMs != null)
        .toList(growable: false);

    String latencySummary = '-';
    if (successful.isNotEmpty) {
      final int sum = successful.fold<int>(
        0,
        (int acc, VpnPingResult item) => acc + (item.latencyMs ?? 0),
      );
      final int avg = (sum / successful.length).round();
      latencySummary = '${avg}ms avg';
    }

    _setMessage(
      'Ping pool: $ok/${results.length} successful ($latencySummary)',
    );

    if (results.isNotEmpty) {
      final String lines = results
          .map(
            (VpnPingResult item) =>
                '${item.tag}: ${item.success ? '${item.latencyMs ?? '-'}ms (${item.checkMethod})' : 'fail (${item.checkMethod})'}',
          )
          .join(' | ');
      _appendLog('ping pool detail: $lines');
    }
  }

  Future<void> _parseSubscriptionText() async {
    final ParsedVpnSubscription parsed = _vpn.parseSubscription(
      _requireSubscription(),
      sbmmPassphrase: _optionalPassphrase(),
    );
    await _logSubscriptionCoreCompatibility(parsed);
    final List<VpnProfileSummary> summaries = _vpn.extractSubscriptionSummaries(
      _requireSubscription(),
      sbmmPassphrase: _optionalPassphrase(),
    );
    _setMessage(
      'Subscription parsed: profiles=${parsed.profiles.length}, failures=${parsed.failures.length}',
    );
    if (summaries.isNotEmpty) {
      _appendLog(
        'subscription tags: ${summaries.take(5).map((VpnProfileSummary s) => s.remark).join(', ')}',
      );
    }
  }

  Future<void> _extractSubscriptionSummariesOnly() async {
    final List<VpnProfileSummary> summaries = _vpn.extractSubscriptionSummaries(
      _requireSubscription(),
      sbmmPassphrase: _optionalPassphrase(),
    );
    _setMessage('Subscription summaries: ${summaries.length}');
    if (summaries.isNotEmpty) {
      _appendLog(
        'top summaries: ${summaries.take(5).map((VpnProfileSummary s) => s.remark).join(', ')}',
      );
    }
  }

  Future<void> _applyEndpointPoolDirect() async {
    final ParsedVpnSubscription parsed = _vpn.parseSubscription(
      _requireSubscription(),
      sbmmPassphrase: _optionalPassphrase(),
    );
    await _logSubscriptionCoreCompatibility(parsed);
    if (parsed.profiles.isEmpty) {
      throw StateError('No valid profiles to apply as endpoint pool.');
    }

    final Map<String, Object?> config = await _vpn.applyEndpointPool(
      profiles: parsed.profiles,
      options: _selectedPreset.endpointPoolOptions,
      bypassPolicy: _selectedPreset.bypassPolicy,
      throttlePolicy: _selectedPreset.throttlePolicy,
      featureSettings: _selectedPreset.featureSettings,
    );
    _rememberAppliedConfig(config, source: 'applyEndpointPool');
    _setMessage(
      'Endpoint pool applied (${parsed.profiles.length} profiles, not started).',
    );
  }

  Future<void> _importSubscription() async {
    final ParsedVpnSubscription parsed = _vpn.parseSubscription(
      _requireSubscription(),
      sbmmPassphrase: _optionalPassphrase(),
    );
    await _logSubscriptionCoreCompatibility(parsed);
    final SubscriptionImportResult result = await _vpn.importSubscription(
      rawSubscription: _subscriptionController.text.trim(),
      source: 'example-ui',
      sbmmPassphrase: _optionalPassphrase(),
      connect: false,
      options: _selectedPreset.endpointPoolOptions,
      bypassPolicy: _selectedPreset.bypassPolicy,
      throttlePolicy: _selectedPreset.throttlePolicy,
      featureSettings: _selectedPreset.featureSettings,
    );
    if (result.appliedConfig != null) {
      _rememberAppliedConfig(
        result.appliedConfig!,
        source: 'importSubscription',
      );
    }
    _setMessage(
      'Imported ${result.importedCount} profiles, invalid=${result.invalidCount}, pool=${result.poolSize}',
    );
  }

  Future<void> _connectAutoSubscription() async {
    final ParsedVpnSubscription parsed = _vpn.parseSubscription(
      _requireSubscription(),
      sbmmPassphrase: _optionalPassphrase(),
    );
    await _logSubscriptionCoreCompatibility(parsed);
    final AutoConnectResult result = await _vpn.connectAutoSubscription(
      rawSubscription: _subscriptionController.text.trim(),
      source: 'example-ui',
      sbmmPassphrase: _optionalPassphrase(),
      options: _selectedPreset.endpointPoolOptions,
      bypassPolicy: _selectedPreset.bypassPolicy,
      throttlePolicy: _selectedPreset.throttlePolicy,
      featureSettings: _selectedPreset.featureSettings,
      preferLowestLatency: true,
    );
    if (result.importResult.appliedConfig != null) {
      _rememberAppliedConfig(
        result.importResult.appliedConfig!,
        source: 'connectAutoSubscription',
      );
    }
    await _refreshSnapshot();
    _setMessage(
      'Auto connected: ${result.selectedProfile?.tag ?? '-'} (imported ${result.importResult.importedCount})',
    );
  }

  Future<void> _connectAutoWithPreset() async {
    final ParsedVpnSubscription parsed = _vpn.parseSubscription(
      _requireSubscription(),
      sbmmPassphrase: _optionalPassphrase(),
    );
    await _logSubscriptionCoreCompatibility(parsed);
    final AutoConnectResult result = await _vpn.connectAutoWithPreset(
      rawSubscription: _subscriptionController.text.trim(),
      source: 'example-ui',
      sbmmPassphrase: _optionalPassphrase(),
      preset: _selectedPreset,
      preferLowestLatency: true,
    );
    if (result.importResult.appliedConfig != null) {
      _rememberAppliedConfig(
        result.importResult.appliedConfig!,
        source: 'connectAutoWithPreset',
      );
    }
    await _refreshSnapshot();
    _setMessage('Auto preset connected: ${result.selectedProfile?.tag ?? '-'}');
  }

  Future<void> _rotateEndpoint() async {
    final VpnProfile? next = await _vpn.rotateEndpoint(reconnect: true);
    await _refreshSnapshot();
    _setMessage('Rotated endpoint: ${next?.tag ?? '-'}');
  }

  Future<void> _selectBestEndpoint() async {
    final VpnProfile? best = await _vpn.selectBestEndpointByPing(
      timeout: const Duration(seconds: 3),
      reconnect: false,
    );
    await _refreshSnapshot();
    _setMessage('Best endpoint selected: ${best?.tag ?? '-'}');
  }

  Future<void> _selectSecondEndpoint() async {
    if (_vpn.endpointPool.length < 2) {
      throw StateError('Need at least 2 endpoints in pool.');
    }
    final VpnProfile? selected = await _vpn.selectEndpoint(
      index: 1,
      reconnect: false,
    );
    await _refreshSnapshot();
    _setMessage('Selected endpoint index=1 -> ${selected?.tag ?? '-'}');
  }

  Future<void> _extractDocEndpoints() async {
    final String? configJson = _lastAppliedConfigJson;
    if (configJson == null || configJson.isEmpty) {
      throw StateError('No applied config in memory yet.');
    }
    final List<SingboxEndpointSummary> endpoints = _vpn.extractConfigEndpoints(
      configJson,
    );
    _setMessage('Document endpoints: ${endpoints.length}');
    if (endpoints.isNotEmpty) {
      final SingboxEndpointSummary first = endpoints.first;
      _appendLog(
        'first endpoint tag=${first.tag ?? '-'} server=${first.server ?? '-'}:${first.serverPort ?? '-'}',
      );
    }
  }

  Future<void> _applyDocPatch() async {
    final String? configJson = _lastAppliedConfigJson;
    if (configJson == null || configJson.isEmpty) {
      throw StateError('No applied config in memory yet.');
    }

    final SingboxConfigDocument document = _vpn.parseConfigDocument(configJson);
    final List<SingboxEndpointSummary> endpoints = document.endpointSummaries();
    if (endpoints.isEmpty) {
      throw StateError('No outbound endpoint found in document.');
    }

    final SingboxEndpointSummary first = endpoints.first;
    final String nextRemark = '${first.remark ?? first.tag ?? 'node'}-demo';
    document.updateEndpoint(
      outboundIndex: first.outboundIndex,
      remark: nextRemark,
    );
    await _vpn.applyConfigDocument(document);

    final String pretty = document.toJson(pretty: true);
    _lastAppliedConfigJson = pretty;
    _lastAppliedConfig = (jsonDecode(pretty) as Map).cast<String, Object?>();

    _setMessage(
      'Config document patch applied: ${first.tag ?? '-'} -> $nextRemark',
    );
  }

  Future<void> _reapplyRawConfig() async {
    final Map<String, Object?>? config = _lastAppliedConfig;
    if (config == null) {
      throw StateError('No applied config in memory yet.');
    }
    await _vpn.setRawConfig(config);
    _setMessage('Re-applied raw config from memory.');
  }

  Widget _actionButton(
    String label,
    Future<void> Function() action, {
    bool enabled = true,
  }) {
    return ElevatedButton(
      onPressed: _busy || !enabled ? null : () => _runAction(label, action),
      child: Text(label),
    );
  }

  Widget _buildSection(String title, List<Widget> children) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
        const SizedBox(height: 8),
        Wrap(spacing: 8, runSpacing: 8, children: children),
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    final VpnProfile? active = _vpn.activeProfile;
    final List<VpnEndpointHealth> endpointHealth = _vpn.endpointHealth;
    final String endpointHealthSummary = endpointHealth.isEmpty
        ? '-'
        : endpointHealth
              .take(3)
              .map((VpnEndpointHealth item) => '${item.tag}:${item.score}')
              .join(' | ');
    final String coreVersion = _coreCapabilities?.displayVersion ?? '-';
    final String currentProtocol = _currentConfigProtocol?.wireValue ?? '-';
    final String currentProtocolStatus =
        switch (_currentConfigProtocolSupported) {
          true => 'supported',
          false => 'unsupported',
          null => 'unknown',
        };
    final bool configProtocolAllowed = _currentConfigProtocolSupported != false;

    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Singbox MM Full API Demo')),
        body: SafeArea(
          child: SingleChildScrollView(
            padding: const EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Card(
                  child: Padding(
                    padding: const EdgeInsets.all(12),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: <Widget>[
                        Text('State: ${_state.wireValue}'),
                        Text(
                          'Detail: ${_stateDetails.detailCode ?? '-'} '
                          '/ validated=${_stateDetails.networkValidated?.toString() ?? 'unknown'}',
                        ),
                        if (_stateDetails.privateDnsServerName != null)
                          Text(
                            'Private DNS: ${_stateDetails.privateDnsServerName}',
                          ),
                        Text('Active Profile: ${active?.tag ?? '-'}'),
                        Text('Endpoint Pool: ${_vpn.endpointPool.length}'),
                        Text('Endpoint Health: $endpointHealthSummary'),
                        Text('Core: $coreVersion'),
                        Text(
                          'Config Protocol: $currentProtocol ($currentProtocolStatus)',
                        ),
                        if (_currentConfigParseError != null &&
                            _currentConfigParseError!.isNotEmpty)
                          Text(
                            'Config Parse: ${_currentConfigParseError!}',
                            style: TextStyle(
                              color: Theme.of(context).colorScheme.error,
                            ),
                          ),
                        const SizedBox(height: 8),
                        Text('Down Speed: ${_stats.formattedDownloadSpeed}'),
                        Text('Up Speed: ${_stats.formattedUploadSpeed}'),
                        Text('Total Down: ${_stats.formattedTotalDownloaded}'),
                        Text('Total Up: ${_stats.formattedTotalUploaded}'),
                        Text('Duration: ${_stats.formattedDuration}'),
                        const SizedBox(height: 8),
                        Text(
                          'Message: $_message',
                          style: const TextStyle(fontWeight: FontWeight.w600),
                        ),
                      ],
                    ),
                  ),
                ),
                const SizedBox(height: 12),
                TextField(
                  controller: _configController,
                  minLines: 1,
                  maxLines: 2,
                  decoration: const InputDecoration(
                    labelText: 'Config Link',
                    border: OutlineInputBorder(),
                  ),
                ),
                const SizedBox(height: 8),
                TextField(
                  controller: _passphraseController,
                  minLines: 1,
                  maxLines: 1,
                  decoration: const InputDecoration(
                    labelText: 'SBMM Passphrase',
                    border: OutlineInputBorder(),
                  ),
                ),
                const SizedBox(height: 8),
                TextField(
                  controller: _secureLinkController,
                  minLines: 1,
                  maxLines: 3,
                  decoration: const InputDecoration(
                    labelText: 'SBMM Secure Link',
                    border: OutlineInputBorder(),
                  ),
                ),
                const SizedBox(height: 8),
                TextField(
                  controller: _subscriptionController,
                  minLines: 3,
                  maxLines: 6,
                  decoration: const InputDecoration(
                    labelText: 'Subscription (newline separated links)',
                    border: OutlineInputBorder(),
                  ),
                ),
                const SizedBox(height: 12),
                DropdownButtonFormField<GfwPresetMode>(
                  initialValue: _selectedPresetMode,
                  decoration: const InputDecoration(
                    labelText: 'Preset',
                    border: OutlineInputBorder(),
                  ),
                  items: _presetPacks
                      .map(
                        (GfwPresetPack preset) =>
                            DropdownMenuItem<GfwPresetMode>(
                              value: preset.mode,
                              child: Text(
                                '${preset.name} (${preset.mode.name})',
                              ),
                            ),
                      )
                      .toList(growable: false),
                  onChanged: _busy
                      ? null
                      : (GfwPresetMode? value) {
                          if (value == null) {
                            return;
                          }
                          setState(() {
                            _selectedPresetMode = value;
                          });
                        },
                ),
                const SizedBox(height: 8),
                Text(
                  _selectedPreset.description,
                  style: Theme.of(context).textTheme.bodySmall,
                ),
                const SizedBox(height: 16),
                _buildSection('Connection', <Widget>[
                  _actionButton('Paste Config', _pasteConfigLink),
                  _actionButton(
                    'Connect Basic',
                    _connectBasic,
                    enabled: configProtocolAllowed,
                  ),
                  _actionButton(
                    'Connect Hardened',
                    _connectHardened,
                    enabled: configProtocolAllowed,
                  ),
                  _actionButton(
                    'Connect Manual Profile',
                    _connectManualProfileDirect,
                    enabled: configProtocolAllowed,
                  ),
                  _actionButton(
                    'Connect Manual Preset',
                    _connectManualPresetDirect,
                    enabled: configProtocolAllowed,
                  ),
                  _actionButton('Connect SBMM', _connectWithSbmmLink),
                  _actionButton('Start', _startVpn),
                  _actionButton('Start Managed', _startManaged),
                  _actionButton('Disconnect', _disconnect),
                  _actionButton('Restart', _restartVpn),
                  _actionButton('Reset Profile', _resetProfile),
                ]),
                const SizedBox(height: 16),
                _buildSection('Config / Profile APIs', <Widget>[
                  _actionButton(
                    'Set Preset Features',
                    _setPresetFeatureSettings,
                  ),
                  _actionButton('List Presets', _listPresetPacksFromApi),
                  _actionButton('Parse Config', _parseCurrentConfig),
                  _actionButton(
                    'Apply Profile',
                    _applyProfileDirect,
                    enabled: configProtocolAllowed,
                  ),
                  _actionButton(
                    'Apply Config Link',
                    _applyConfigLinkOnly,
                    enabled: configProtocolAllowed,
                  ),
                  _actionButton('Wrap SBMM', _wrapConfigLink),
                  _actionButton('Unwrap SBMM', _unwrapSecureLink),
                  _actionButton('Validate Active', _validateActiveProfile),
                  _actionButton('Doc Endpoints', _extractDocEndpoints),
                  _actionButton('Doc Patch Apply', _applyDocPatch),
                  _actionButton('Reapply Raw Config', _reapplyRawConfig),
                ]),
                const SizedBox(height: 16),
                _buildSection('Subscription / Endpoint Pool APIs', <Widget>[
                  _actionButton('Parse Subscription', _parseSubscriptionText),
                  _actionButton(
                    'Extract Summaries',
                    _extractSubscriptionSummariesOnly,
                  ),
                  _actionButton('Import Subscription', _importSubscription),
                  _actionButton(
                    'Apply Endpoint Pool',
                    _applyEndpointPoolDirect,
                  ),
                  _actionButton('Auto Connect', _connectAutoSubscription),
                  _actionButton('Auto Connect Preset', _connectAutoWithPreset),
                  _actionButton('Rotate Endpoint', _rotateEndpoint),
                  _actionButton('Select Best Endpoint', _selectBestEndpoint),
                  _actionButton('Select Endpoint #2', _selectSecondEndpoint),
                  _actionButton('Ping Pool', _pingEndpointPool),
                ]),
                const SizedBox(height: 16),
                _buildSection('Runtime / Diagnostics APIs', <Widget>[
                  _actionButton('Refresh Snapshot', _refreshSnapshot),
                  _actionButton('Request VPN Perm', _requestVpnPermission),
                  _actionButton(
                    'Request Noti Perm',
                    _requestNotificationPermission,
                  ),
                  _actionButton('Sync Runtime', _syncRuntime),
                  _actionButton('Ping Active', _pingActiveProfile),
                  _actionButton('Probe Connectivity', _probeConnectivity),
                  _actionButton('Run Diagnostics', _runDiagnostics),
                  _actionButton('Load Version', _loadVersion),
                  _actionButton('Core Capabilities', _loadCoreCapabilities),
                  _actionButton('Load Last Error', _loadLastError),
                ]),
                const SizedBox(height: 16),
                Text(
                  'Activity Log',
                  style: Theme.of(context).textTheme.titleMedium,
                ),
                const SizedBox(height: 8),
                Container(
                  width: double.infinity,
                  constraints: const BoxConstraints(minHeight: 120),
                  decoration: BoxDecoration(
                    border: Border.all(color: Theme.of(context).dividerColor),
                    borderRadius: BorderRadius.circular(8),
                  ),
                  padding: const EdgeInsets.all(8),
                  child: SelectableText(
                    _activityLog.reversed.join('\n'),
                    style: const TextStyle(fontSize: 12),
                  ),
                ),
                const SizedBox(height: 12),
                if (_busy)
                  const Row(
                    children: <Widget>[
                      SizedBox(
                        height: 16,
                        width: 16,
                        child: CircularProgressIndicator(strokeWidth: 2),
                      ),
                      SizedBox(width: 8),
                      Text('Running action...'),
                    ],
                  ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class _DemoLifecycleObserver with WidgetsBindingObserver {
  _DemoLifecycleObserver({required this.onResume});

  final Future<void> Function() onResume;

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.resumed) {
      unawaited(onResume());
    }
  }
}
2
likes
0
points
241
downloads

Publisher

unverified uploader

Weekly Downloads

Flutter VPN plugin with sing-box runtime bridge, routing presets, and anti-throttling config builder.

Repository (GitHub)
View/report issues

Topics

#vpn #sing-box #flutter-plugin #networking #proxy

Documentation

Documentation

License

unknown (license)

Dependencies

flutter, plugin_platform_interface, pointycastle

More

Packages that depend on singbox_mm

Packages that implement singbox_mm