arblant
firefox-vpn-client / app / lib / core / android_core.dart
android_core.dart195 lines · 6.4 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';

import 'core_api.dart';

/// An app installed on the phone.
@immutable
class InstalledApp {
  const InstalledApp(this.package, this.label);

  final String package;
  final String label;
}

/// Which apps use the VPN (CoreService.kt), in v2rayN's words: bypass the
/// selected apps ([bypass]), or proxy only the selected apps ([only]).
class AppRouting {
  AppRouting({
    required this.onlyChosen,
    required this.bypass,
    required this.only,
    required this.suggested,
  });

  /// Proxy selected apps; otherwise bypass them.
  bool onlyChosen;

  /// Bypass selected apps: these connect directly.
  final Set<String> bypass;

  /// Proxy selected apps: only these use the VPN.
  final Set<String> only;

  /// The routing region's apps (from the core), suggested for bypassing:
  /// checked at first, then only by the user's choice.
  final Set<String> suggested;

  Set<String> get _chosen => onlyChosen ? only : bypass;

  /// Whether [package] is checked in the current mode.
  bool checked(String package) => _chosen.contains(package);

  void toggle(String package, bool on) =>
      on ? _chosen.add(package) : _chosen.remove(package);

  /// Checks the suggested apps that are installed; returns how many were
  /// not checked yet.
  int checkSuggested(Iterable<String> installed) {
    final add = installed.where(suggested.contains).toSet()..removeAll(bypass);
    bypass.addAll(add);
    return add.length;
  }

  /// A key that changes with the choice, to know whether to apply it.
  String get key => [
    onlyChosen,
    ...[bypass, only].map((s) => (s.toList()..sort()).join(',')),
  ].join('|');
}

/// The user did not allow the app to set up a VPN.
class VpnNotAllowed implements Exception {
  const VpnNotAllowed();
}

/// The core on Android: it runs in this app's process. It starts with the
/// proxy off while the UI is open, so settings and accounts work; turned
/// on, a foreground service (android/.../CoreService.kt) keeps it running
/// for other apps while the UI is closed. The UI talks to it through the
/// same HTTP API.
class AndroidCore {
  static const _channel = MethodChannel('app/android');

  /// The core's data folder inside the app's private storage.
  static Future<String> dataDir() async =>
      await _channel.invokeMethod<String>('dataDir') ?? '';

  AndroidCore(this._dataDir);

  final String _dataDir;

  /// Opens a client of the core, starting the core (off) when it does not
  /// run; it throws [CoreUnavailable] until the core answers.
  Future<CoreApi> connect() async {
    if (await _channel.invokeMethod<bool>('isRunning') != true) {
      await _channel.invokeMethod<void>('startCore');
      throw const CoreUnavailable(CoreProblem.starting);
    }
    try {
      final api = CoreApi(await CoreLocator.locate(_dataDir));
      try {
        await api.status();
        return api;
      } on Exception {
        api.close();
        rethrow;
      }
    } on CoreUnavailable {
      // Started, and its API is still coming up.
      throw const CoreUnavailable(CoreProblem.starting);
    }
  }

  /// Turns the VPN on (asking the user for the VPN permission the first
  /// time) or off. It throws [VpnNotAllowed] when the user refuses.
  static Future<void> setOn(bool on) async {
    try {
      await _channel.invokeMethod<void>('setOn', {'on': on});
    } on PlatformException catch (e) {
      if (e.code == 'vpn_denied') throw const VpnNotAllowed();
      throw CoreApiException(0, e.message ?? e.code);
    }
  }

  /// The connection mode: 'vpn' (every app on the phone) or 'proxy' (only
  /// apps set up to use the local proxy). Read at start ([loadMode]).
  static final mode = ValueNotifier<String>('vpn');

  static Future<void> loadMode() async {
    mode.value = await _channel.invokeMethod<String>('connectionMode') ?? 'vpn';
    showSpeed.value = await _channel.invokeMethod<bool>('showSpeed') ?? false;
  }

  /// Whether the notification shows the speed (CoreService.kt reads it).
  static final showSpeed = ValueNotifier<bool>(false);

  static Future<void> setShowSpeed(bool on) async {
    await _channel.invokeMethod<void>('setShowSpeed', {'on': on});
    showSpeed.value = on;
  }

  /// Keeps the mode; a running proxy switches at once. Switching to the
  /// VPN the first time turns it off and on again, so Android asks for the
  /// VPN permission; it throws [VpnNotAllowed] when the user refuses.
  static Future<void> setMode(String value) async {
    try {
      await _channel.invokeMethod<void>('setConnectionMode', {'mode': value});
      mode.value = value;
    } on PlatformException catch (e) {
      if (e.code != 'vpn_denied') rethrow;
      await setOn(false);
      await _channel.invokeMethod<void>('setConnectionMode', {'mode': value});
      mode.value = value;
      await setOn(true);
    }
  }

  /// The apps with a launcher icon, but this one.
  static Future<List<InstalledApp>> installedApps() async {
    final list = await _channel.invokeListMethod<Map<Object?, Object?>>(
      'installedApps',
    );
    return [
      for (final m in list ?? const <Map<Object?, Object?>>[])
        InstalledApp('${m['package']}', '${m['label']}'),
    ];
  }

  /// An app's icon as PNG.
  static Future<Uint8List?> appIcon(String package) =>
      _channel.invokeMethod<Uint8List>('appIcon', {'package': package});

  /// Which apps use the VPN.
  static Future<AppRouting> appRouting() async {
    final m =
        await _channel.invokeMapMethod<String, Object?>('appRouting') ??
        const {};
    Set<String> set(String key) => {
      for (final p in (m[key] as List<Object?>? ?? const [])) '$p',
    };
    return AppRouting(
      onlyChosen: m['mode'] == 'include',
      bypass: set('bypass'),
      only: set('only'),
      suggested: set('region'),
    );
  }

  /// Keeps which apps use the VPN; [apply] sets a running VPN up again.
  static Future<void> setAppRouting(AppRouting r, {required bool apply}) =>
      _channel.invokeMethod<void>('setAppRouting', {
        'mode': r.onlyChosen ? 'include' : 'exclude',
        'bypass': r.bypass.toList(),
        'only': r.only.toList(),
        'apply': apply,
      });

  /// The phone's main processor type, such as arm64-v8a.
  static Future<String> abi() async =>
      await _channel.invokeMethod<String>('abi') ?? '';

  /// Opens [url] in the app that handles it (such as a tg:// link).
  static Future<bool> openUrl(String url) async =>
      await _channel.invokeMethod<bool>('openUrl', {'url': url}) ?? false;
}