diff --git a/.circleci/config.yml b/.circleci/config.yml index 985bb39cc0..bd152d1d1d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -21,10 +21,25 @@ jobs: command: | echo 'export PATH="$HOME"/.cargo/bin:"$PATH"' >> "$BASH_ENV" - run: rustup target add armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android + - run: + name: Install Flutter SDK + command: | + git clone --depth 1 --branch stable https://github.com/flutter/flutter.git "$HOME/flutter" + echo 'export PATH="$HOME/flutter/bin:$PATH"' >> "$BASH_ENV" + source "$BASH_ENV" + flutter precache --android + - run: + name: Configure Flutter module + command: | + echo "flutter.sdk=$HOME/flutter" >> local.properties + cd flutter_module && flutter pub get + - run: + name: Flutter tests + command: cd flutter_module && flutter test - android/restore-gradle-cache - run: name: Run Build and Tests - command: ./gradlew assembleDebug check -PCARGO_PROFILE=debug + command: ./gradlew assembleDebug check -PCARGO_PROFILE=debug -x :mobile:releaseOssLicensesTask -x :mobile:debugOssLicensesTask - android/save-gradle-cache - store_artifacts: path: mobile/build/outputs/apk diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index e1c3eda7b2..d65724da0e 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -23,6 +23,18 @@ jobs: with: targets: x86_64-linux-android + - uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Configure Flutter module + run: | + echo "flutter.sdk=$(which flutter | xargs dirname | xargs dirname)" >> local.properties + cd flutter_module && flutter pub get + + - name: Flutter tests + run: cd flutter_module && flutter test + - name: Enable KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ diff --git a/.gitignore b/.gitignore index 62b7bc5e37..57528ee4e5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ release/ # release apks *.apk .DS_Store + +# E2E test screenshots +screen_*.png diff --git a/buildSrc/src/main/kotlin/Helpers.kt b/buildSrc/src/main/kotlin/Helpers.kt index 7c9304dcde..026757a429 100644 --- a/buildSrc/src/main/kotlin/Helpers.kt +++ b/buildSrc/src/main/kotlin/Helpers.kt @@ -25,7 +25,7 @@ fun Project.setupCommon() { android.apply { compileSdkVersion(36) defaultConfig { - minSdk = 23 + minSdk = 24 targetSdk = 36 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/flutter_module/.gitignore b/flutter_module/.gitignore new file mode 100644 index 0000000000..5a091758dc --- /dev/null +++ b/flutter_module/.gitignore @@ -0,0 +1,49 @@ +.DS_Store +.dart_tool/ + +.pub/ + +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +migrate_working_dir/ + +*.swp +profile + +DerivedData/ + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +build/ +.android/ +.ios/ +README.md +.flutter-plugins-dependencies + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json diff --git a/flutter_module/.metadata b/flutter_module/.metadata new file mode 100644 index 0000000000..a833b0066b --- /dev/null +++ b/flutter_module/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536" + channel: "stable" + +project_type: module diff --git a/flutter_module/analysis_options.yaml b/flutter_module/analysis_options.yaml new file mode 100644 index 0000000000..a5744c1cfb --- /dev/null +++ b/flutter_module/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/flutter_module/lib/app.dart b/flutter_module/lib/app.dart new file mode 100644 index 0000000000..b496a75a6c --- /dev/null +++ b/flutter_module/lib/app.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'screens/about/about_screen.dart'; +import 'screens/app_manager/app_manager_screen.dart'; +import 'screens/custom_rules/custom_rules_screen.dart'; +import 'screens/home/home_screen.dart'; +import 'screens/profile_config/profile_config_screen.dart'; +import 'screens/scanner/scanner_screen.dart'; +import 'screens/settings/settings_screen.dart'; +import 'screens/subscriptions/subscriptions_screen.dart'; +import 'theme/app_theme.dart'; + +final _router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const HomeScreen(), + ), + GoRoute( + path: '/profile/new', + builder: (context, state) => const ProfileConfigScreen(), + ), + GoRoute( + path: '/profile/:id', + builder: (context, state) => ProfileConfigScreen( + profileId: state.pathParameters['id'], + ), + ), + GoRoute( + path: '/settings', + builder: (context, state) => const SettingsScreen(), + ), + GoRoute( + path: '/subscriptions', + builder: (context, state) => const SubscriptionsScreen(), + ), + GoRoute( + path: '/custom-rules', + builder: (context, state) => const CustomRulesScreen(), + ), + GoRoute( + path: '/scanner', + builder: (context, state) => const ScannerScreen(), + ), + GoRoute( + path: '/app-manager', + builder: (context, state) => const AppManagerScreen(), + ), + GoRoute( + path: '/about', + builder: (context, state) => const AboutScreen(), + ), + ], +); + +class ShadowsocksApp extends StatelessWidget { + const ShadowsocksApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp.router( + title: 'Shadowsocks', + debugShowCheckedModeBanner: false, + theme: AppTheme.light, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.system, + routerConfig: _router, + ); + } +} diff --git a/flutter_module/lib/channels/profile_channel.dart b/flutter_module/lib/channels/profile_channel.dart new file mode 100644 index 0000000000..89c27be5ca --- /dev/null +++ b/flutter_module/lib/channels/profile_channel.dart @@ -0,0 +1,73 @@ +import 'package:flutter/services.dart'; +import '../models/profile.dart'; + +class ProfileChannel { + static const _method = MethodChannel('com.github.shadowsocks/profiles'); + + Future> getProfiles() async { + final result = await _method.invokeListMethod('getProfiles'); + return result?.map((m) => Profile.fromMap(m)).toList() ?? []; + } + + Future getProfile(int id) async { + final result = + await _method.invokeMapMethod('getProfile', {'id': id}); + return result != null ? Profile.fromMap(result) : null; + } + + Future createProfile(Map data) async { + final result = + await _method.invokeMapMethod('createProfile', data); + return result != null ? Profile.fromMap(result) : null; + } + + Future updateProfile(Map data) async { + final result = await _method.invokeMethod('updateProfile', data); + return result ?? false; + } + + Future deleteProfile(int id) async { + final result = + await _method.invokeMethod('deleteProfile', {'id': id}); + return result ?? false; + } + + Future selectProfile(int id) async { + await _method.invokeMethod('selectProfile', {'id': id}); + } + + Future getSelectedId() async { + final result = await _method.invokeMethod('getSelectedId'); + return result ?? 0; + } + + Future reorderProfiles(List> order) async { + await _method.invokeMethod('reorderProfiles', {'order': order}); + } + + Future importFromText(String text) async { + final result = + await _method.invokeMethod('importFromText', {'text': text}); + return result ?? 0; + } + + Future importFromJson(String json) async { + final result = + await _method.invokeMethod('importFromJson', {'json': json}); + return result ?? 0; + } + + Future exportToJson() async { + final result = await _method.invokeMethod('exportToJson'); + return result ?? '[]'; + } + + Future getProfileUri(int id) async { + return await _method.invokeMethod('getProfileUri', {'id': id}); + } + + Future> getPlugins() async { + final result = await _method.invokeListMethod('getPlugins'); + return result ?? []; + } +} diff --git a/flutter_module/lib/channels/service_channel.dart b/flutter_module/lib/channels/service_channel.dart new file mode 100644 index 0000000000..a21075bd58 --- /dev/null +++ b/flutter_module/lib/channels/service_channel.dart @@ -0,0 +1,42 @@ +import 'package:flutter/services.dart'; +import '../models/service_state.dart'; + +class ServiceChannel { + static const _method = MethodChannel('com.github.shadowsocks/service'); + static const _stateEvent = EventChannel('com.github.shadowsocks/state'); + + Future getState() async { + final state = await _method.invokeMethod('getState'); + return ServiceState.fromValue(state ?? 0); + } + + Future toggle() async { + final result = await _method.invokeMethod('toggle'); + return result ?? false; + } + + Future requestVpnPermission() async { + final result = await _method.invokeMethod('requestVpnPermission'); + return result ?? false; + } + + Future testConnection() async { + return await _method.invokeMethod('testConnection'); + } + + /// Launches the plugin's native configuration activity. + /// Returns {'status': 'ok', 'options': '...'}, {'status': 'fallback'}, or {'status': 'cancelled'}. + Future> configurePlugin(String pluginId, String options) async { + final result = await _method.invokeMapMethod( + 'configurePlugin', + {'pluginId': pluginId, 'options': options}, + ); + return result ?? {'status': 'fallback'}; + } + + Stream get stateStream { + return _stateEvent.receiveBroadcastStream().map((event) { + return ServiceStatus.fromMap(event as Map); + }); + } +} diff --git a/flutter_module/lib/channels/settings_channel.dart b/flutter_module/lib/channels/settings_channel.dart new file mode 100644 index 0000000000..8b5fc274eb --- /dev/null +++ b/flutter_module/lib/channels/settings_channel.dart @@ -0,0 +1,73 @@ +import 'package:flutter/services.dart'; + +class SettingsChannel { + static const _method = MethodChannel('com.github.shadowsocks/settings'); + + Future getString(String key) async { + return await _method.invokeMethod('getString', {'key': key}); + } + + Future putString(String key, String value) async { + await _method.invokeMethod('putString', {'key': key, 'value': value}); + } + + Future getBool(String key) async { + return await _method.invokeMethod('getBool', {'key': key}); + } + + Future putBool(String key, bool value) async { + await _method.invokeMethod('putBool', {'key': key, 'value': value}); + } + + Future getServiceMode() async { + return await _method.invokeMethod('getServiceMode') ?? 'vpn'; + } + + Future setServiceMode(String mode) async { + await _method.invokeMethod('setServiceMode', {'mode': mode}); + } + + Future getPortProxy() async { + return await _method.invokeMethod('getPortProxy') ?? 1080; + } + + Future setPortProxy(int port) async { + await _method.invokeMethod('setPortProxy', {'port': port}); + } + + Future getPortLocalDns() async { + return await _method.invokeMethod('getPortLocalDns') ?? 5450; + } + + Future setPortLocalDns(int port) async { + await _method.invokeMethod('setPortLocalDns', {'port': port}); + } + + Future getPortTransproxy() async { + return await _method.invokeMethod('getPortTransproxy') ?? 8200; + } + + Future setPortTransproxy(int port) async { + await _method.invokeMethod('setPortTransproxy', {'port': port}); + } + + Future getPersistAcrossReboot() async { + return await _method.invokeMethod('getPersistAcrossReboot') ?? false; + } + + Future setPersistAcrossReboot(bool value) async { + await _method.invokeMethod('setPersistAcrossReboot', {'value': value}); + } + + Future getDirectBootAware() async { + return await _method.invokeMethod('getDirectBootAware') ?? false; + } + + Future setDirectBootAware(bool value) async { + await _method.invokeMethod('setDirectBootAware', {'value': value}); + } + + Future getVersion() async { + return await _method.invokeMethod('getVersion') ?? ''; + } +} diff --git a/flutter_module/lib/channels/traffic_channel.dart b/flutter_module/lib/channels/traffic_channel.dart new file mode 100644 index 0000000000..064c6641e1 --- /dev/null +++ b/flutter_module/lib/channels/traffic_channel.dart @@ -0,0 +1,12 @@ +import 'package:flutter/services.dart'; +import '../models/traffic_stats.dart'; + +class TrafficChannel { + static const _event = EventChannel('com.github.shadowsocks/traffic'); + + Stream get trafficStream { + return _event.receiveBroadcastStream().map((event) { + return TrafficStats.fromMap(event as Map); + }); + } +} diff --git a/flutter_module/lib/main.dart b/flutter_module/lib/main.dart new file mode 100644 index 0000000000..a5bfd71f01 --- /dev/null +++ b/flutter_module/lib/main.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'app.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp( + const ProviderScope( + child: ShadowsocksApp(), + ), + ); +} diff --git a/flutter_module/lib/models/profile.dart b/flutter_module/lib/models/profile.dart new file mode 100644 index 0000000000..c95ab940ee --- /dev/null +++ b/flutter_module/lib/models/profile.dart @@ -0,0 +1,171 @@ +class Profile { + final int id; + final String name; + final String host; + final int remotePort; + final String password; + final String method; + final String route; + final String remoteDns; + final bool proxyApps; + final bool bypass; + final bool udpdns; + final bool ipv6; + final bool metered; + final String individual; + final String? plugin; + final int? udpFallback; + final int subscription; + final int tx; + final int rx; + final int userOrder; + + const Profile({ + required this.id, + this.name = '', + required this.host, + this.remotePort = 8388, + required this.password, + this.method = 'chacha20-ietf-poly1305', + this.route = 'all', + this.remoteDns = 'dns.google', + this.proxyApps = false, + this.bypass = false, + this.udpdns = false, + this.ipv6 = false, + this.metered = false, + this.individual = '', + this.plugin, + this.udpFallback, + this.subscription = 0, + this.tx = 0, + this.rx = 0, + this.userOrder = 0, + }); + + factory Profile.fromMap(Map map) { + return Profile( + id: map['id'] as int, + name: (map['name'] as String?) ?? '', + host: (map['host'] as String?) ?? '', + remotePort: (map['remotePort'] as int?) ?? 8388, + password: (map['password'] as String?) ?? '', + method: (map['method'] as String?) ?? 'chacha20-ietf-poly1305', + route: (map['route'] as String?) ?? 'all', + remoteDns: (map['remoteDns'] as String?) ?? 'dns.google', + proxyApps: (map['proxyApps'] as bool?) ?? false, + bypass: (map['bypass'] as bool?) ?? false, + udpdns: (map['udpdns'] as bool?) ?? false, + ipv6: (map['ipv6'] as bool?) ?? false, + metered: (map['metered'] as bool?) ?? false, + individual: (map['individual'] as String?) ?? '', + plugin: map['plugin'] as String?, + udpFallback: map['udpFallback'] as int?, + subscription: (map['subscription'] as int?) ?? 0, + tx: (map['tx'] as int?) ?? 0, + rx: (map['rx'] as int?) ?? 0, + userOrder: (map['userOrder'] as int?) ?? 0, + ); + } + + Map toMap() { + return { + 'id': id, + 'name': name, + 'host': host, + 'remotePort': remotePort, + 'password': password, + 'method': method, + 'route': route, + 'remoteDns': remoteDns, + 'proxyApps': proxyApps, + 'bypass': bypass, + 'udpdns': udpdns, + 'ipv6': ipv6, + 'metered': metered, + 'individual': individual, + 'plugin': plugin, + 'udpFallback': udpFallback, + 'subscription': subscription, + 'tx': tx, + 'rx': rx, + 'userOrder': userOrder, + }; + } + + Profile copyWith({ + int? id, + String? name, + String? host, + int? remotePort, + String? password, + String? method, + String? route, + String? remoteDns, + bool? proxyApps, + bool? bypass, + bool? udpdns, + bool? ipv6, + bool? metered, + String? individual, + String? plugin, + int? udpFallback, + int? subscription, + int? tx, + int? rx, + int? userOrder, + }) { + return Profile( + id: id ?? this.id, + name: name ?? this.name, + host: host ?? this.host, + remotePort: remotePort ?? this.remotePort, + password: password ?? this.password, + method: method ?? this.method, + route: route ?? this.route, + remoteDns: remoteDns ?? this.remoteDns, + proxyApps: proxyApps ?? this.proxyApps, + bypass: bypass ?? this.bypass, + udpdns: udpdns ?? this.udpdns, + ipv6: ipv6 ?? this.ipv6, + metered: metered ?? this.metered, + individual: individual ?? this.individual, + plugin: plugin ?? this.plugin, + udpFallback: udpFallback ?? this.udpFallback, + subscription: subscription ?? this.subscription, + tx: tx ?? this.tx, + rx: rx ?? this.rx, + userOrder: userOrder ?? this.userOrder, + ); + } + + String get displayName => name.isNotEmpty ? name : '$host:$remotePort'; + + String get methodDisplay => method; + + static const encryptionMethods = [ + 'chacha20-ietf-poly1305', + 'aes-256-gcm', + 'aes-128-gcm', + '2022-blake3-aes-256-gcm', + '2022-blake3-aes-128-gcm', + '2022-blake3-chacha20-poly1305', + 'xchacha20-ietf-poly1305', + 'aes-256-cfb', + 'aes-192-cfb', + 'aes-128-cfb', + 'chacha20-ietf', + 'rc4-md5', + 'none', + ]; + + static const routeOptions = { + 'all': 'All', + 'bypass-lan': 'Bypass LAN', + 'bypass-china': 'Bypass China', + 'bypass-lan-china': 'Bypass LAN & China', + 'gfwlist': 'GFW List', + 'china-list': 'China List', + 'custom-rules': 'Custom Rules', + }; +} diff --git a/flutter_module/lib/models/service_state.dart b/flutter_module/lib/models/service_state.dart new file mode 100644 index 0000000000..03a12ed4a6 --- /dev/null +++ b/flutter_module/lib/models/service_state.dart @@ -0,0 +1,41 @@ +enum ServiceState { + idle(0), + connecting(1), + connected(2), + stopping(3), + stopped(4); + + final int value; + const ServiceState(this.value); + + factory ServiceState.fromValue(int value) { + return ServiceState.values.firstWhere( + (s) => s.value == value, + orElse: () => ServiceState.idle, + ); + } + + bool get isStarted => this == connected; + bool get isBusy => this == connecting || this == stopping; + bool get canToggle => !isBusy; +} + +class ServiceStatus { + final ServiceState state; + final String? profileName; + final String? message; + + const ServiceStatus({ + this.state = ServiceState.idle, + this.profileName, + this.message, + }); + + factory ServiceStatus.fromMap(Map map) { + return ServiceStatus( + state: ServiceState.fromValue(map['state'] as int? ?? 0), + profileName: map['profileName'] as String?, + message: map['message'] as String?, + ); + } +} diff --git a/flutter_module/lib/models/traffic_stats.dart b/flutter_module/lib/models/traffic_stats.dart new file mode 100644 index 0000000000..e5d35d7f23 --- /dev/null +++ b/flutter_module/lib/models/traffic_stats.dart @@ -0,0 +1,38 @@ +class TrafficStats { + final int profileId; + final int txRate; + final int rxRate; + final int txTotal; + final int rxTotal; + + const TrafficStats({ + this.profileId = 0, + this.txRate = 0, + this.rxRate = 0, + this.txTotal = 0, + this.rxTotal = 0, + }); + + factory TrafficStats.fromMap(Map map) { + return TrafficStats( + profileId: (map['profileId'] as int?) ?? 0, + txRate: (map['txRate'] as int?) ?? 0, + rxRate: (map['rxRate'] as int?) ?? 0, + txTotal: (map['txTotal'] as int?) ?? 0, + rxTotal: (map['rxTotal'] as int?) ?? 0, + ); + } + + static String formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; + } + + static String formatSpeed(int bytesPerSec) { + return '${formatBytes(bytesPerSec)}/s'; + } +} diff --git a/flutter_module/lib/providers/profiles_provider.dart b/flutter_module/lib/providers/profiles_provider.dart new file mode 100644 index 0000000000..c16c293c89 --- /dev/null +++ b/flutter_module/lib/providers/profiles_provider.dart @@ -0,0 +1,77 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../channels/profile_channel.dart'; +import '../models/profile.dart'; + +final profileChannelProvider = Provider((ref) => ProfileChannel()); + +final profilesProvider = + AsyncNotifierProvider>( + ProfilesNotifier.new); + +class ProfilesNotifier extends AsyncNotifier> { + ProfileChannel get _channel => ref.read(profileChannelProvider); + + @override + Future> build() => _channel.getProfiles(); + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() => _channel.getProfiles()); + } + + Future selectProfile(int id) async { + await ref.read(selectedProfileIdProvider.notifier).select(id); + state = await AsyncValue.guard(() => _channel.getProfiles()); + } + + Future deleteProfile(int id) async { + await _channel.deleteProfile(id); + state = await AsyncValue.guard(() => _channel.getProfiles()); + } + + Future createProfile(Map data) async { + final profile = await _channel.createProfile(data); + state = await AsyncValue.guard(() => _channel.getProfiles()); + return profile; + } + + Future updateProfile(Map data) async { + await _channel.updateProfile(data); + state = await AsyncValue.guard(() => _channel.getProfiles()); + } + + Future importFromText(String text) async { + final count = await _channel.importFromText(text); + if (count > 0) { + state = await AsyncValue.guard(() => _channel.getProfiles()); + } + return count; + } + + Future importFromJson(String json) async { + final count = await _channel.importFromJson(json); + state = await AsyncValue.guard(() => _channel.getProfiles()); + return count; + } + + Future exportToJson() => _channel.exportToJson(); +} + +final selectedProfileIdProvider = + AsyncNotifierProvider( + SelectedProfileNotifier.new); + +class SelectedProfileNotifier extends AsyncNotifier { + @override + Future build() async { + final channel = ref.read(profileChannelProvider); + return channel.getSelectedId(); + } + + Future select(int id) async { + final channel = ref.read(profileChannelProvider); + await channel.selectProfile(id); + state = AsyncData(id); + } +} diff --git a/flutter_module/lib/providers/service_provider.dart b/flutter_module/lib/providers/service_provider.dart new file mode 100644 index 0000000000..8948c12396 --- /dev/null +++ b/flutter_module/lib/providers/service_provider.dart @@ -0,0 +1,28 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../channels/service_channel.dart'; +import '../models/service_state.dart'; + +final serviceChannelProvider = Provider((ref) => ServiceChannel()); + +final serviceStateProvider = + StreamProvider.autoDispose((ref) async* { + final channel = ref.watch(serviceChannelProvider); + + // Emit initial state + final initialState = await channel.getState(); + yield ServiceStatus(state: initialState); + + // Then stream updates + yield* channel.stateStream; +}); + +final currentStateProvider = Provider((ref) { + return ref.watch(serviceStateProvider).valueOrNull?.state ?? + ServiceState.idle; +}); + +final serviceToggleProvider = Provider Function()>((ref) { + final channel = ref.read(serviceChannelProvider); + return () => channel.toggle(); +}); diff --git a/flutter_module/lib/providers/settings_provider.dart b/flutter_module/lib/providers/settings_provider.dart new file mode 100644 index 0000000000..39eabb67d9 --- /dev/null +++ b/flutter_module/lib/providers/settings_provider.dart @@ -0,0 +1,96 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../channels/settings_channel.dart'; + +final settingsChannelProvider = Provider((ref) => SettingsChannel()); + +class SettingsState { + final String serviceMode; + final int portProxy; + final int portLocalDns; + final int portTransproxy; + final bool persistAcrossReboot; + final bool directBootAware; + + const SettingsState({ + this.serviceMode = 'vpn', + this.portProxy = 1080, + this.portLocalDns = 5450, + this.portTransproxy = 8200, + this.persistAcrossReboot = false, + this.directBootAware = false, + }); + + SettingsState copyWith({ + String? serviceMode, + int? portProxy, + int? portLocalDns, + int? portTransproxy, + bool? persistAcrossReboot, + bool? directBootAware, + }) { + return SettingsState( + serviceMode: serviceMode ?? this.serviceMode, + portProxy: portProxy ?? this.portProxy, + portLocalDns: portLocalDns ?? this.portLocalDns, + portTransproxy: portTransproxy ?? this.portTransproxy, + persistAcrossReboot: persistAcrossReboot ?? this.persistAcrossReboot, + directBootAware: directBootAware ?? this.directBootAware, + ); + } +} + +final settingsProvider = + AsyncNotifierProvider( + SettingsNotifier.new); + +class SettingsNotifier extends AsyncNotifier { + SettingsChannel get _channel => ref.read(settingsChannelProvider); + + @override + Future build() async { + return SettingsState( + serviceMode: await _channel.getServiceMode(), + portProxy: await _channel.getPortProxy(), + portLocalDns: await _channel.getPortLocalDns(), + portTransproxy: await _channel.getPortTransproxy(), + persistAcrossReboot: await _channel.getPersistAcrossReboot(), + directBootAware: await _channel.getDirectBootAware(), + ); + } + + Future setServiceMode(String mode) async { + await _channel.setServiceMode(mode); + state = AsyncData(state.value!.copyWith(serviceMode: mode)); + } + + Future setPortProxy(int port) async { + await _channel.setPortProxy(port); + state = AsyncData(state.value!.copyWith(portProxy: port)); + } + + Future setPortLocalDns(int port) async { + await _channel.setPortLocalDns(port); + state = AsyncData(state.value!.copyWith(portLocalDns: port)); + } + + Future setPortTransproxy(int port) async { + await _channel.setPortTransproxy(port); + state = AsyncData(state.value!.copyWith(portTransproxy: port)); + } + + Future setPersistAcrossReboot(bool value) async { + await _channel.setPersistAcrossReboot(value); + state = AsyncData(state.value!.copyWith(persistAcrossReboot: value)); + } + + Future setDirectBootAware(bool value) async { + await _channel.setDirectBootAware(value); + state = AsyncData(state.value!.copyWith(directBootAware: value)); + } +} + +final appVersionProvider = FutureProvider((ref) async { + final channel = ref.read(settingsChannelProvider); + return channel.getVersion(); +}); diff --git a/flutter_module/lib/providers/traffic_provider.dart b/flutter_module/lib/providers/traffic_provider.dart new file mode 100644 index 0000000000..78a9c59f2b --- /dev/null +++ b/flutter_module/lib/providers/traffic_provider.dart @@ -0,0 +1,11 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../channels/traffic_channel.dart'; +import '../models/traffic_stats.dart'; + +final trafficChannelProvider = Provider((ref) => TrafficChannel()); + +final trafficStatsProvider = + StreamProvider.autoDispose((ref) { + final channel = ref.watch(trafficChannelProvider); + return channel.trafficStream; +}); diff --git a/flutter_module/lib/screens/about/about_screen.dart b/flutter_module/lib/screens/about/about_screen.dart new file mode 100644 index 0000000000..a1c14d54ad --- /dev/null +++ b/flutter_module/lib/screens/about/about_screen.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../providers/settings_provider.dart'; + +class AboutScreen extends ConsumerWidget { + const AboutScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final version = ref.watch(appVersionProvider); + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar(title: const Text('About')), + body: ListView( + padding: const EdgeInsets.all(24), + children: [ + const SizedBox(height: 32), + // App icon and name + Center( + child: Container( + width: 96, + height: 96, + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(24), + ), + child: Icon( + Icons.vpn_key_rounded, + size: 48, + color: theme.colorScheme.onPrimaryContainer, + ), + ), + ), + const SizedBox(height: 24), + Text( + 'Shadowsocks', + textAlign: TextAlign.center, + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + version.when( + data: (v) => Text( + 'v$v', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + ), + const SizedBox(height: 8), + Text( + 'A secure SOCKS5 proxy for Android', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant.withAlpha(179), + ), + ), + const SizedBox(height: 48), + // Info cards + Card( + child: Column( + children: [ + ListTile( + leading: Icon(Icons.code_rounded, + color: theme.colorScheme.primary), + title: const Text('Source Code'), + subtitle: const Text('github.com/shadowsocks/shadowsocks-android'), + trailing: const Icon(Icons.open_in_new_rounded, size: 18), + onTap: () { + // TODO: open URL via platform channel + }, + ), + const Divider(height: 1, indent: 56), + ListTile( + leading: Icon(Icons.description_rounded, + color: theme.colorScheme.primary), + title: const Text('Open Source Licenses'), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () => showLicensePage( + context: context, + applicationName: 'Shadowsocks', + ), + ), + const Divider(height: 1, indent: 56), + ListTile( + leading: Icon(Icons.help_outline_rounded, + color: theme.colorScheme.primary), + title: const Text('FAQ'), + trailing: const Icon(Icons.open_in_new_rounded, size: 18), + onTap: () { + // TODO: open FAQ URL + }, + ), + ], + ), + ), + const SizedBox(height: 24), + Text( + 'Copyright (C) 2017 by Max Lv & Mygod Studio\n' + 'Licensed under GPLv3', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant.withAlpha(128), + ), + ), + ], + ), + ); + } +} diff --git a/flutter_module/lib/screens/app_manager/app_manager_screen.dart b/flutter_module/lib/screens/app_manager/app_manager_screen.dart new file mode 100644 index 0000000000..80558402f8 --- /dev/null +++ b/flutter_module/lib/screens/app_manager/app_manager_screen.dart @@ -0,0 +1,238 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final _appListChannel = Provider( + (ref) => const MethodChannel('com.github.shadowsocks/applist'), +); + +class AppInfo { + final String packageName; + final String label; + final int uid; + bool isProxied; + + AppInfo({ + required this.packageName, + required this.label, + required this.uid, + required this.isProxied, + }); + + factory AppInfo.fromMap(Map map) { + return AppInfo( + packageName: map['package'] as String, + label: (map['name'] as String?) ?? map['package'] as String, + uid: (map['uid'] as int?) ?? 0, + isProxied: (map['isProxied'] as bool?) ?? false, + ); + } +} + +class _AppListConfig { + final bool enabled; + final bool bypass; + final List apps; + + const _AppListConfig({ + this.enabled = false, + this.bypass = false, + this.apps = const [], + }); +} + +final _appListProvider = + AsyncNotifierProvider<_AppListNotifier, _AppListConfig>( + _AppListNotifier.new); + +class _AppListNotifier extends AsyncNotifier<_AppListConfig> { + MethodChannel get _channel => ref.read(_appListChannel); + + @override + Future<_AppListConfig> build() async { + final config = + await _channel.invokeMapMethod('getProxyAppsConfig'); + final appsRaw = await _channel.invokeListMethod('getApps'); + final apps = appsRaw?.map((m) => AppInfo.fromMap(m)).toList() ?? []; + apps.sort((a, b) => a.label.toLowerCase().compareTo(b.label.toLowerCase())); + return _AppListConfig( + enabled: config?['enabled'] as bool? ?? false, + bypass: config?['bypass'] as bool? ?? false, + apps: apps, + ); + } + + void toggleApp(String packageName) { + final current = state.valueOrNull; + if (current == null) return; + final apps = current.apps; + final idx = apps.indexWhere((a) => a.packageName == packageName); + if (idx >= 0) { + apps[idx].isProxied = !apps[idx].isProxied; + state = AsyncData(_AppListConfig( + enabled: current.enabled, + bypass: current.bypass, + apps: apps, + )); + } + } + + void toggleBypass() { + final current = state.valueOrNull; + if (current == null) return; + state = AsyncData(_AppListConfig( + enabled: current.enabled, + bypass: !current.bypass, + apps: current.apps, + )); + } + + Future save() async { + final current = state.valueOrNull; + if (current == null) return; + final packages = current.apps + .where((a) => a.isProxied) + .map((a) => a.packageName) + .toList(); + await _channel.invokeMethod('setProxiedApps', { + 'packages': packages, + 'bypass': current.bypass, + }); + } +} + +class AppManagerScreen extends ConsumerStatefulWidget { + const AppManagerScreen({super.key}); + + @override + ConsumerState createState() => _AppManagerScreenState(); +} + +class _AppManagerScreenState extends ConsumerState { + String _search = ''; + final Map _iconCache = {}; + + @override + Widget build(BuildContext context) { + final config = ref.watch(_appListProvider); + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('Per-App Proxy'), + actions: [ + IconButton( + icon: const Icon(Icons.check_rounded), + onPressed: () async { + await ref.read(_appListProvider.notifier).save(); + if (mounted) Navigator.of(context).pop(); + }, + ), + ], + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: TextField( + decoration: const InputDecoration( + hintText: 'Search apps...', + prefixIcon: Icon(Icons.search_rounded), + ), + onChanged: (v) => setState(() => _search = v), + ), + ), + config.whenData((c) => SwitchListTile( + title: const Text('Bypass Mode'), + subtitle: Text( + c.bypass + ? 'Selected apps bypass the proxy' + : 'Only selected apps use the proxy', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + value: c.bypass, + onChanged: (_) => + ref.read(_appListProvider.notifier).toggleBypass(), + )).value ?? const SizedBox.shrink(), + const Divider(height: 1), + Expanded( + child: config.when( + data: (c) { + final filtered = c.apps.where((a) { + if (_search.isEmpty) return true; + return a.label + .toLowerCase() + .contains(_search.toLowerCase()) || + a.packageName + .toLowerCase() + .contains(_search.toLowerCase()); + }).toList(); + return ListView.builder( + itemCount: filtered.length, + itemBuilder: (context, index) { + final app = filtered[index]; + return CheckboxListTile( + value: app.isProxied, + onChanged: (_) => ref + .read(_appListProvider.notifier) + .toggleApp(app.packageName), + title: Text(app.label, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text( + app.packageName, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + secondary: _buildAppIcon(app.packageName), + ); + }, + ); + }, + loading: () => + const Center(child: CircularProgressIndicator()), + error: (error, _) => + Center(child: Text('Error: $error')), + ), + ), + ], + ), + ); + } + + Widget _buildAppIcon(String packageName) { + if (_iconCache.containsKey(packageName)) { + final bytes = _iconCache[packageName]; + if (bytes != null) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.memory(bytes, width: 40, height: 40), + ); + } + } else { + _loadIcon(packageName); + } + return const SizedBox( + width: 40, + height: 40, + child: Icon(Icons.android_rounded), + ); + } + + void _loadIcon(String packageName) async { + _iconCache[packageName] = null; // mark loading + final channel = ref.read(_appListChannel); + try { + final bytes = await channel + .invokeMethod('getAppIcon', {'package': packageName}); + if (mounted) { + setState(() => _iconCache[packageName] = bytes); + } + } catch (_) { + // icon loading failed, use placeholder + } + } +} diff --git a/flutter_module/lib/screens/custom_rules/custom_rules_screen.dart b/flutter_module/lib/screens/custom_rules/custom_rules_screen.dart new file mode 100644 index 0000000000..4f1715997b --- /dev/null +++ b/flutter_module/lib/screens/custom_rules/custom_rules_screen.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final _aclChannelProvider = Provider( + (ref) => const MethodChannel('com.github.shadowsocks/acl'), +); + +class AclRules { + final List subnets; + final List hostnames; + final List urls; + + const AclRules({ + this.subnets = const [], + this.hostnames = const [], + this.urls = const [], + }); + + List get all => [...subnets, ...hostnames, ...urls]; +} + +final aclRulesProvider = + AsyncNotifierProvider(AclRulesNotifier.new); + +class AclRulesNotifier extends AsyncNotifier { + MethodChannel get _channel => ref.read(_aclChannelProvider); + + @override + Future build() async { + final result = + await _channel.invokeMapMethod('getRules'); + if (result == null) return const AclRules(); + return AclRules( + subnets: + (result['subnets'] as List?)?.cast() ?? [], + hostnames: + (result['hostnames'] as List?)?.cast() ?? [], + urls: (result['urls'] as List?)?.cast() ?? [], + ); + } + + Future addRule(String rule, String type) async { + await _channel.invokeMethod('addRule', {'rule': rule, 'type': type}); + state = await AsyncValue.guard(() => build()); + } + + Future removeRule(String rule, String type) async { + await _channel.invokeMethod('removeRule', {'rule': rule, 'type': type}); + state = await AsyncValue.guard(() => build()); + } +} + +class CustomRulesScreen extends ConsumerWidget { + const CustomRulesScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final rules = ref.watch(aclRulesProvider); + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar(title: const Text('Custom Rules')), + body: rules.when( + data: (r) { + final allRules = <_RuleEntry>[ + ...r.subnets.map((s) => _RuleEntry(s, 'subnet', Icons.lan_rounded)), + ...r.hostnames + .map((h) => _RuleEntry(h, 'hostname', Icons.language_rounded)), + ...r.urls.map((u) => _RuleEntry(u, 'url', Icons.link_rounded)), + ]; + if (allRules.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.rule_rounded, + size: 64, + color: theme.colorScheme.onSurfaceVariant.withAlpha(77)), + const SizedBox(height: 16), + Text('No custom rules', + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant)), + ], + ), + ); + } + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: allRules.length, + itemBuilder: (context, index) { + final entry = allRules[index]; + return Dismissible( + key: ValueKey('${entry.type}:${entry.rule}'), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 24), + color: theme.colorScheme.error, + child: Icon(Icons.delete_outline_rounded, + color: theme.colorScheme.onError), + ), + onDismissed: (_) { + ref + .read(aclRulesProvider.notifier) + .removeRule(entry.rule, entry.type); + }, + child: ListTile( + leading: CircleAvatar( + backgroundColor: theme.colorScheme.surfaceContainerHighest, + child: Icon(entry.icon, + size: 20, + color: theme.colorScheme.onSurfaceVariant), + ), + title: Text(entry.rule, + style: theme.textTheme.bodyMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis), + subtitle: Text(entry.type, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant)), + ), + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center(child: Text('Error: $error')), + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _showAddDialog(context, ref), + child: const Icon(Icons.add_rounded), + ), + ); + } + + void _showAddDialog(BuildContext context, WidgetRef ref) { + final controller = TextEditingController(); + String type = 'hostname'; + + showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: const Text('Add Rule'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SegmentedButton( + segments: const [ + ButtonSegment(value: 'hostname', label: Text('Domain')), + ButtonSegment(value: 'subnet', label: Text('Subnet')), + ButtonSegment(value: 'url', label: Text('URL')), + ], + selected: {type}, + onSelectionChanged: (v) => setState(() => type = v.first), + ), + const SizedBox(height: 16), + TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + labelText: type == 'hostname' + ? 'Domain' + : type == 'subnet' + ? 'CIDR Subnet' + : 'URL', + hintText: type == 'hostname' + ? 'example.com' + : type == 'subnet' + ? '192.168.0.0/16' + : 'https://...', + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final rule = controller.text.trim(); + if (rule.isNotEmpty) { + ref.read(aclRulesProvider.notifier).addRule(rule, type); + Navigator.of(context).pop(); + } + }, + child: const Text('Add'), + ), + ], + ), + ), + ); + } +} + +class _RuleEntry { + final String rule; + final String type; + final IconData icon; + const _RuleEntry(this.rule, this.type, this.icon); +} diff --git a/flutter_module/lib/screens/home/home_screen.dart b/flutter_module/lib/screens/home/home_screen.dart new file mode 100644 index 0000000000..42dd8e892d --- /dev/null +++ b/flutter_module/lib/screens/home/home_screen.dart @@ -0,0 +1,338 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../models/service_state.dart'; +import '../../providers/profiles_provider.dart'; +import '../../providers/service_provider.dart'; +import 'widgets/profile_card.dart'; +import 'widgets/service_fab.dart'; +import 'widgets/stats_bar.dart'; + +class HomeScreen extends ConsumerWidget { + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final profiles = ref.watch(profilesProvider); + final selectedId = ref.watch(selectedProfileIdProvider); + final state = ref.watch(currentStateProvider); + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('Shadowsocks'), + leading: Builder( + builder: (context) => IconButton( + icon: const Icon(Icons.menu_rounded), + onPressed: () => Scaffold.of(context).openDrawer(), + ), + ), + actions: [ + PopupMenuButton( + icon: const Icon(Icons.add_rounded), + onSelected: (value) => _handleImport(context, ref, value), + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'scan', + child: ListTile( + leading: Icon(Icons.qr_code_scanner_rounded), + title: Text('Scan QR Code'), + contentPadding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + ), + const PopupMenuItem( + value: 'clipboard', + child: ListTile( + leading: Icon(Icons.content_paste_rounded), + title: Text('Import from Clipboard'), + contentPadding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + ), + const PopupMenuItem( + value: 'manual', + child: ListTile( + leading: Icon(Icons.edit_note_rounded), + title: Text('Manual Settings'), + contentPadding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + ), + ], + ), + PopupMenuButton( + onSelected: (value) => _handleMenu(context, ref, value), + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'export', + child: Text('Export to Clipboard'), + ), + const PopupMenuItem( + value: 'sort_name', + child: Text('Sort by Name'), + ), + ], + ), + ], + ), + drawer: _buildDrawer(context), + body: Column( + children: [ + Expanded( + child: profiles.when( + data: (list) { + if (list.isEmpty) { + return _buildEmptyState(context); + } + return ListView.builder( + padding: const EdgeInsets.only(top: 8, bottom: 100), + itemCount: list.length, + itemBuilder: (context, index) { + final profile = list[index]; + final isSelected = + selectedId.valueOrNull == profile.id; + return Dismissible( + key: ValueKey(profile.id), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 24), + color: theme.colorScheme.error, + child: Icon(Icons.delete_outline_rounded, + color: theme.colorScheme.onError), + ), + confirmDismiss: (direction) async { + return await _confirmDelete(context, profile.displayName); + }, + onDismissed: (_) { + ref + .read(profilesProvider.notifier) + .deleteProfile(profile.id); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text('${profile.displayName} deleted'), + action: SnackBarAction( + label: 'Undo', + onPressed: () { + ref + .read(profilesProvider.notifier) + .createProfile(profile.toMap()); + }, + ), + ), + ); + }, + child: ProfileCard( + profile: profile, + isSelected: isSelected, + isConnected: + isSelected && state == ServiceState.connected, + onTap: () => ref + .read(profilesProvider.notifier) + .selectProfile(profile.id), + onEdit: () => context + .push('/profile/${profile.id}'), + onShare: () => _shareProfile(context, ref, profile.id), + ), + ); + }, + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline_rounded, + size: 48, + color: theme.colorScheme.error), + const SizedBox(height: 16), + Text('Failed to load profiles', + style: theme.textTheme.bodyLarge), + const SizedBox(height: 8), + FilledButton.tonal( + onPressed: () => + ref.read(profilesProvider.notifier).refresh(), + child: const Text('Retry'), + ), + ], + ), + ), + ), + ), + const StatsBar(), + ], + ), + floatingActionButton: const ServiceFab(), + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + ); + } + + Widget _buildEmptyState(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(48), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.vpn_key_off_rounded, + size: 80, + color: theme.colorScheme.onSurfaceVariant.withAlpha(77), + ), + const SizedBox(height: 24), + Text( + 'No profiles yet', + style: theme.textTheme.headlineSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Text( + 'Add a server profile to get started.\nTap + to scan a QR code or enter manually.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant.withAlpha(153), + ), + ), + ], + ), + ), + ); + } + + Widget _buildDrawer(BuildContext context) { + final theme = Theme.of(context); + return NavigationDrawer( + selectedIndex: 0, + onDestinationSelected: (index) { + Navigator.of(context).pop(); + switch (index) { + case 0: + break; // already on profiles + case 1: + context.push('/subscriptions'); + case 2: + context.push('/custom-rules'); + case 3: + context.push('/settings'); + case 4: + context.push('/about'); + } + }, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(28, 24, 16, 16), + child: Text( + 'Shadowsocks', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.primary, + ), + ), + ), + const Divider(indent: 28, endIndent: 28), + const SizedBox(height: 8), + const NavigationDrawerDestination( + icon: Icon(Icons.vpn_key_outlined), + selectedIcon: Icon(Icons.vpn_key), + label: Text('Profiles'), + ), + const NavigationDrawerDestination( + icon: Icon(Icons.sync_outlined), + selectedIcon: Icon(Icons.sync), + label: Text('Subscriptions'), + ), + const NavigationDrawerDestination( + icon: Icon(Icons.rule_outlined), + selectedIcon: Icon(Icons.rule), + label: Text('Custom Rules'), + ), + const NavigationDrawerDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: Text('Settings'), + ), + const NavigationDrawerDestination( + icon: Icon(Icons.info_outline_rounded), + selectedIcon: Icon(Icons.info_rounded), + label: Text('About'), + ), + ], + ); + } + + void _handleImport(BuildContext context, WidgetRef ref, String value) async { + switch (value) { + case 'scan': + context.push('/scanner'); + case 'clipboard': + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (data?.text != null && data!.text!.isNotEmpty) { + final count = await ref + .read(profilesProvider.notifier) + .importFromText(data.text!); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(count > 0 + ? 'Imported $count profile(s)' + : 'No valid profiles found'), + ), + ); + } + } + case 'manual': + context.push('/profile/new'); + } + } + + void _handleMenu(BuildContext context, WidgetRef ref, String value) async { + if (value == 'export') { + final json = await ref.read(profilesProvider.notifier).exportToJson(); + await Clipboard.setData(ClipboardData(text: json)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Profiles exported to clipboard')), + ); + } + } + } + + Future _confirmDelete(BuildContext context, String name) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Profile'), + content: Text('Delete "$name"?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + } + + void _shareProfile(BuildContext context, WidgetRef ref, int id) async { + final channel = ref.read(profileChannelProvider); + final uri = await channel.getProfileUri(id); + if (uri != null && context.mounted) { + await Clipboard.setData(ClipboardData(text: uri)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Profile URI copied to clipboard')), + ); + } + } +} diff --git a/flutter_module/lib/screens/home/widgets/profile_card.dart b/flutter_module/lib/screens/home/widgets/profile_card.dart new file mode 100644 index 0000000000..cea67e6bb4 --- /dev/null +++ b/flutter_module/lib/screens/home/widgets/profile_card.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; +import '../../../models/profile.dart'; +import '../../../models/traffic_stats.dart'; + +class ProfileCard extends StatelessWidget { + final Profile profile; + final bool isSelected; + final bool isConnected; + final VoidCallback onTap; + final VoidCallback onEdit; + final VoidCallback onShare; + final VoidCallback? onDismissed; + + const ProfileCard({ + super.key, + required this.profile, + required this.isSelected, + this.isConnected = false, + required this.onTap, + required this.onEdit, + required this.onShare, + this.onDismissed, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + final selectedColor = + isConnected ? colorScheme.secondary : colorScheme.primary; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + color: isSelected + ? selectedColor.withAlpha(20) + : theme.cardTheme.color, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: isSelected + ? BorderSide(color: selectedColor, width: 2) + : BorderSide(color: colorScheme.outlineVariant.withAlpha(77)), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // Selection indicator + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected + ? (isConnected + ? colorScheme.secondary + : colorScheme.primary) + : colorScheme.surfaceContainerHighest, + ), + child: Icon( + isSelected + ? (isConnected + ? Icons.flash_on_rounded + : Icons.check_rounded) + : Icons.vpn_key_outlined, + size: 20, + color: isSelected + ? Colors.white + : colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 16), + // Profile info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + profile.displayName, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: + isSelected ? FontWeight.w600 : FontWeight.w500, + color: isSelected + ? selectedColor + : colorScheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + '${profile.host}:${profile.remotePort}', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (profile.plugin != null && + profile.plugin!.isNotEmpty) ...[ + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + profile.plugin!.split('/').last, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onTertiaryContainer, + ), + ), + ), + ], + if (profile.tx > 0 || profile.rx > 0) ...[ + const SizedBox(height: 6), + Row( + children: [ + Icon(Icons.arrow_upward_rounded, + size: 12, color: colorScheme.primary), + const SizedBox(width: 2), + Text( + TrafficStats.formatBytes(profile.tx), + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant + .withAlpha(179), + ), + ), + const SizedBox(width: 12), + Icon(Icons.arrow_downward_rounded, + size: 12, color: colorScheme.secondary), + const SizedBox(width: 2), + Text( + TrafficStats.formatBytes(profile.rx), + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant + .withAlpha(179), + ), + ), + ], + ), + ], + ], + ), + ), + // Action buttons + Column( + children: [ + IconButton( + icon: Icon( + Icons.edit_outlined, + color: colorScheme.onSurfaceVariant, + ), + iconSize: 20, + visualDensity: VisualDensity.compact, + onPressed: onEdit, + ), + IconButton( + icon: Icon( + Icons.share_outlined, + color: colorScheme.onSurfaceVariant, + ), + iconSize: 20, + visualDensity: VisualDensity.compact, + onPressed: onShare, + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/flutter_module/lib/screens/home/widgets/service_fab.dart b/flutter_module/lib/screens/home/widgets/service_fab.dart new file mode 100644 index 0000000000..cd3fb682e0 --- /dev/null +++ b/flutter_module/lib/screens/home/widgets/service_fab.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../models/service_state.dart'; +import '../../../providers/service_provider.dart'; + +class ServiceFab extends ConsumerWidget { + const ServiceFab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(currentStateProvider); + final theme = Theme.of(context); + + return SizedBox( + width: 72, + height: 72, + child: Stack( + alignment: Alignment.center, + children: [ + // Progress ring for busy states + if (state.isBusy) + SizedBox( + width: 72, + height: 72, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation( + theme.colorScheme.secondary.withAlpha(179), + ), + ), + ), + // Main FAB + FloatingActionButton.large( + onPressed: state.canToggle + ? () => ref.read(serviceToggleProvider)() + : null, + backgroundColor: _fabColor(state, theme), + elevation: state.isBusy ? 0 : 4, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: Icon( + _fabIcon(state), + key: ValueKey(state), + size: 32, + color: Colors.white, + ), + ), + ), + ], + ), + ); + } + + Color _fabColor(ServiceState state, ThemeData theme) { + switch (state) { + case ServiceState.connected: + return theme.colorScheme.secondary; + case ServiceState.connecting: + case ServiceState.stopping: + return theme.colorScheme.secondary.withAlpha(153); + default: + return theme.colorScheme.surfaceContainerHighest; + } + } + + IconData _fabIcon(ServiceState state) { + switch (state) { + case ServiceState.connected: + return Icons.flash_on_rounded; + case ServiceState.connecting: + return Icons.hourglass_top_rounded; + case ServiceState.stopping: + return Icons.hourglass_bottom_rounded; + default: + return Icons.flash_off_rounded; + } + } +} diff --git a/flutter_module/lib/screens/home/widgets/stats_bar.dart b/flutter_module/lib/screens/home/widgets/stats_bar.dart new file mode 100644 index 0000000000..3403838cb2 --- /dev/null +++ b/flutter_module/lib/screens/home/widgets/stats_bar.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../models/traffic_stats.dart'; +import '../../../providers/service_provider.dart'; +import '../../../providers/traffic_provider.dart'; + +class StatsBar extends ConsumerWidget { + const StatsBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(currentStateProvider); + final traffic = ref.watch(trafficStatsProvider); + final theme = Theme.of(context); + + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + height: state.isStarted ? 56 : 0, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withAlpha(128), + border: Border( + top: BorderSide( + color: theme.colorScheme.outlineVariant.withAlpha(77), + ), + ), + ), + child: traffic.when( + data: (stats) => _StatsContent(stats: stats), + loading: () => const _StatsContent(stats: TrafficStats()), + error: (_, __) => const _StatsContent(stats: TrafficStats()), + ), + ); + } +} + +class _StatsContent extends StatelessWidget { + final TrafficStats stats; + + const _StatsContent({required this.stats}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: [ + // Upload + Icon( + Icons.arrow_upward_rounded, + size: 16, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 4), + Text( + TrafficStats.formatSpeed(stats.txRate), + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + const SizedBox(width: 8), + Text( + TrafficStats.formatBytes(stats.txTotal), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant.withAlpha(153), + ), + ), + const Spacer(), + // Download + Text( + TrafficStats.formatBytes(stats.rxTotal), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant.withAlpha(153), + ), + ), + const SizedBox(width: 8), + Text( + TrafficStats.formatSpeed(stats.rxRate), + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + const SizedBox(width: 4), + Icon( + Icons.arrow_downward_rounded, + size: 16, + color: theme.colorScheme.secondary, + ), + ], + ), + ); + } +} diff --git a/flutter_module/lib/screens/profile_config/profile_config_screen.dart b/flutter_module/lib/screens/profile_config/profile_config_screen.dart new file mode 100644 index 0000000000..663a9d18e5 --- /dev/null +++ b/flutter_module/lib/screens/profile_config/profile_config_screen.dart @@ -0,0 +1,471 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../channels/service_channel.dart'; +import '../../models/profile.dart'; +import '../../providers/profiles_provider.dart'; +import '../../providers/service_provider.dart'; + +class ProfileConfigScreen extends ConsumerStatefulWidget { + final String? profileId; + + const ProfileConfigScreen({super.key, this.profileId}); + + bool get isNew => profileId == null || profileId == 'new'; + + @override + ConsumerState createState() => + _ProfileConfigScreenState(); +} + +class _PluginInfo { + final String id; + final String label; + final String defaultConfig; + + const _PluginInfo({ + required this.id, + required this.label, + this.defaultConfig = '', + }); +} + +class _ProfileConfigScreenState extends ConsumerState { + final _formKey = GlobalKey(); + late TextEditingController _nameCtrl; + late TextEditingController _hostCtrl; + late TextEditingController _portCtrl; + late TextEditingController _passwordCtrl; + late TextEditingController _remoteDnsCtrl; + late TextEditingController _pluginOptsCtrl; + String _method = 'chacha20-ietf-poly1305'; + String _route = 'all'; + String _selectedPluginId = ''; + List<_PluginInfo> _plugins = []; + bool _ipv6 = false; + bool _metered = false; + bool _udpdns = false; + bool _isDirty = false; + bool _loading = true; + + @override + void initState() { + super.initState(); + _nameCtrl = TextEditingController(); + _hostCtrl = TextEditingController(); + _portCtrl = TextEditingController(text: '8388'); + _passwordCtrl = TextEditingController(); + _remoteDnsCtrl = TextEditingController(text: 'dns.google'); + _pluginOptsCtrl = TextEditingController(); + _loadProfile(); + } + + Future _loadProfile() async { + final channel = ref.read(profileChannelProvider); + + // Fetch installed plugins + try { + final pluginMaps = await channel.getPlugins(); + _plugins = pluginMaps + .map((m) => _PluginInfo( + id: (m['id'] as String?) ?? '', + label: (m['label'] as String?) ?? '', + defaultConfig: (m['defaultConfig'] as String?) ?? '', + )) + .toList(); + } catch (_) { + _plugins = []; + } + + if (!widget.isNew) { + final profile = await channel.getProfile(int.parse(widget.profileId!)); + if (profile != null && mounted) { + setState(() { + _nameCtrl.text = profile.name; + _hostCtrl.text = profile.host; + _portCtrl.text = profile.remotePort.toString(); + _passwordCtrl.text = profile.password; + _remoteDnsCtrl.text = profile.remoteDns; + _method = profile.method; + _route = profile.route; + _ipv6 = profile.ipv6; + _metered = profile.metered; + _udpdns = profile.udpdns; + _parsePlugin(profile.plugin); + }); + } + } + if (mounted) setState(() => _loading = false); + } + + @override + void dispose() { + _nameCtrl.dispose(); + _hostCtrl.dispose(); + _portCtrl.dispose(); + _passwordCtrl.dispose(); + _remoteDnsCtrl.dispose(); + _pluginOptsCtrl.dispose(); + super.dispose(); + } + + void _markDirty() { + if (!_isDirty) setState(() => _isDirty = true); + } + + Future _onWillPop() async { + if (!_isDirty) return true; + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Discard changes?'), + content: const Text('You have unsaved changes.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Keep Editing'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Discard'), + ), + ], + ), + ); + return result ?? false; + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + + final data = { + 'name': _nameCtrl.text, + 'host': _hostCtrl.text, + 'remotePort': int.tryParse(_portCtrl.text) ?? 8388, + 'password': _passwordCtrl.text, + 'method': _method, + 'route': _route, + 'remoteDns': _remoteDnsCtrl.text, + 'ipv6': _ipv6, + 'metered': _metered, + 'udpdns': _udpdns, + 'plugin': _buildPluginString(), + }; + + if (widget.isNew) { + await ref.read(profilesProvider.notifier).createProfile(data); + } else { + data['id'] = int.parse(widget.profileId!); + await ref.read(profilesProvider.notifier).updateProfile(data); + } + + if (mounted) { + _isDirty = false; + context.pop(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return PopScope( + canPop: !_isDirty, + onPopInvokedWithResult: (didPop, _) async { + if (didPop) return; + final shouldPop = await _onWillPop(); + if (shouldPop && mounted) context.pop(); + }, + child: Scaffold( + appBar: AppBar( + title: Text(widget.isNew ? 'New Profile' : 'Edit Profile'), + actions: [ + FilledButton.icon( + onPressed: _save, + icon: const Icon(Icons.check_rounded, size: 18), + label: const Text('Save'), + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ), + const SizedBox(width: 12), + ], + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : Form( + key: _formKey, + onChanged: _markDirty, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _sectionHeader('Server'), + const SizedBox(height: 8), + TextFormField( + controller: _nameCtrl, + decoration: const InputDecoration( + labelText: 'Profile Name', + hintText: 'Optional', + prefixIcon: Icon(Icons.label_outline_rounded), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _hostCtrl, + decoration: const InputDecoration( + labelText: 'Server Address', + hintText: 'hostname or IP', + prefixIcon: Icon(Icons.dns_outlined), + ), + validator: (v) => + v == null || v.isEmpty ? 'Required' : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _portCtrl, + decoration: const InputDecoration( + labelText: 'Server Port', + prefixIcon: Icon(Icons.numbers_rounded), + ), + keyboardType: TextInputType.number, + validator: (v) { + final port = int.tryParse(v ?? ''); + if (port == null || port < 1 || port > 65535) { + return 'Enter a valid port (1-65535)'; + } + return null; + }, + ), + const SizedBox(height: 12), + TextFormField( + controller: _passwordCtrl, + decoration: const InputDecoration( + labelText: 'Password', + prefixIcon: Icon(Icons.lock_outline_rounded), + ), + obscureText: true, + validator: (v) => + v == null || v.isEmpty ? 'Required' : null, + ), + const SizedBox(height: 24), + _sectionHeader('Encryption'), + const SizedBox(height: 8), + DropdownButtonFormField( + initialValue: _method, + decoration: const InputDecoration( + labelText: 'Encrypt Method', + prefixIcon: Icon(Icons.security_rounded), + ), + items: Profile.encryptionMethods + .map((m) => DropdownMenuItem( + value: m, + child: Text(m, + style: + theme.textTheme.bodyMedium), + )) + .toList(), + onChanged: (v) { + if (v != null) { + setState(() => _method = v); + _markDirty(); + } + }, + ), + const SizedBox(height: 24), + _sectionHeader('Routing'), + const SizedBox(height: 8), + DropdownButtonFormField( + initialValue: _route, + decoration: const InputDecoration( + labelText: 'Route', + prefixIcon: Icon(Icons.alt_route_rounded), + ), + items: Profile.routeOptions.entries + .map((e) => DropdownMenuItem( + value: e.key, + child: Text(e.value), + )) + .toList(), + onChanged: (v) { + if (v != null) { + setState(() => _route = v); + _markDirty(); + } + }, + ), + const SizedBox(height: 12), + TextFormField( + controller: _remoteDnsCtrl, + decoration: const InputDecoration( + labelText: 'Remote DNS', + prefixIcon: Icon(Icons.dns_rounded), + ), + ), + const SizedBox(height: 24), + _sectionHeader('Plugin'), + const SizedBox(height: 8), + DropdownButtonFormField( + value: _plugins.any((p) => p.id == _selectedPluginId) + ? _selectedPluginId + : '', + decoration: const InputDecoration( + labelText: 'Plugin', + prefixIcon: Icon(Icons.extension_rounded), + ), + items: [ + const DropdownMenuItem( + value: '', + child: Text('None'), + ), + ..._plugins + .where((p) => p.id.isNotEmpty) + .map((p) => DropdownMenuItem( + value: p.id, + child: Text(p.label.isNotEmpty + ? p.label + : p.id), + )), + ], + onChanged: (v) { + setState(() { + _selectedPluginId = v ?? ''; + if (_selectedPluginId.isNotEmpty && + _pluginOptsCtrl.text.isEmpty) { + final plugin = _plugins.firstWhere( + (p) => p.id == _selectedPluginId, + orElse: () => const _PluginInfo(id: '', label: ''), + ); + if (plugin.defaultConfig.isNotEmpty) { + _pluginOptsCtrl.text = plugin.defaultConfig; + } + } + }); + _markDirty(); + }, + ), + if (_selectedPluginId.isNotEmpty) ...[ + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _pluginOptsCtrl, + decoration: const InputDecoration( + labelText: 'Plugin Options', + hintText: 'key=value;key=value', + prefixIcon: Icon(Icons.tune_rounded), + ), + maxLines: null, + ), + ), + const SizedBox(width: 8), + IconButton.filled( + icon: const Icon(Icons.settings_rounded), + tooltip: 'Configure plugin', + onPressed: () => _launchPluginConfig(), + ), + ], + ), + ], + const SizedBox(height: 24), + _sectionHeader('Options'), + const SizedBox(height: 8), + _switchTile( + 'IPv6 Route', + 'Route IPv6 traffic through the proxy', + _ipv6, + (v) => setState(() { + _ipv6 = v; + _markDirty(); + }), + ), + _switchTile( + 'Metered Network', + 'Treat VPN as metered connection', + _metered, + (v) => setState(() { + _metered = v; + _markDirty(); + }), + ), + _switchTile( + 'UDP over TCP', + 'Send UDP DNS queries via TCP', + _udpdns, + (v) => setState(() { + _udpdns = v; + _markDirty(); + }), + ), + const SizedBox(height: 12), + ListTile( + leading: const Icon(Icons.apps_rounded), + title: const Text('Per-App Proxy'), + subtitle: const Text('Select apps to proxy'), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () => context.push('/app-manager'), + ), + const SizedBox(height: 80), + ], + ), + ), + ), + ); + } + + Widget _sectionHeader(String title) { + final theme = Theme.of(context); + return Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ); + } + + Future _launchPluginConfig() async { + final serviceChannel = ref.read(serviceChannelProvider); + final result = await serviceChannel.configurePlugin( + _selectedPluginId, + _pluginOptsCtrl.text, + ); + final status = result['status'] as String?; + if (status == 'ok' && mounted) { + setState(() { + _pluginOptsCtrl.text = (result['options'] as String?) ?? ''; + _markDirty(); + }); + } + // 'fallback' and 'cancelled' — just keep the manual text field as-is + } + + void _parsePlugin(String? plugin) { + if (plugin == null || plugin.isEmpty) return; + final semicolon = plugin.indexOf(';'); + if (semicolon < 0) { + _selectedPluginId = plugin; + } else { + _selectedPluginId = plugin.substring(0, semicolon); + _pluginOptsCtrl.text = plugin.substring(semicolon + 1); + } + } + + String _buildPluginString() { + if (_selectedPluginId.isEmpty) return ''; + final opts = _pluginOptsCtrl.text.trim(); + return opts.isEmpty ? _selectedPluginId : '$_selectedPluginId;$opts'; + } + + Widget _switchTile( + String title, String subtitle, bool value, ValueChanged onChanged) { + return SwitchListTile( + title: Text(title), + subtitle: Text(subtitle), + value: value, + onChanged: onChanged, + contentPadding: const EdgeInsets.symmetric(horizontal: 4), + ); + } +} diff --git a/flutter_module/lib/screens/scanner/scanner_screen.dart b/flutter_module/lib/screens/scanner/scanner_screen.dart new file mode 100644 index 0000000000..0d9770c503 --- /dev/null +++ b/flutter_module/lib/screens/scanner/scanner_screen.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../providers/profiles_provider.dart'; + +/// QR scanner that delegates to the native Android scanner via platform channel. +/// This avoids the need for camera-related Flutter plugins. +class ScannerScreen extends ConsumerStatefulWidget { + const ScannerScreen({super.key}); + + @override + ConsumerState createState() => _ScannerScreenState(); +} + +class _ScannerScreenState extends ConsumerState { + static const _channel = MethodChannel('com.github.shadowsocks/scanner'); + + @override + void initState() { + super.initState(); + _startScan(); + } + + Future _startScan() async { + try { + final result = await _channel.invokeMethod('scan'); + if (result != null && result.isNotEmpty && mounted) { + final count = + await ref.read(profilesProvider.notifier).importFromText(result); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(count > 0 + ? 'Imported $count profile(s)' + : 'No valid profiles found'), + ), + ); + context.pop(); + } + } else if (mounted) { + context.pop(); + } + } on PlatformException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Scanner error: ${e.message}')), + ); + context.pop(); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar(title: const Text('Scan QR Code')), + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(color: theme.colorScheme.secondary), + const SizedBox(height: 24), + Text( + 'Opening camera...', + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter_module/lib/screens/settings/settings_screen.dart b/flutter_module/lib/screens/settings/settings_screen.dart new file mode 100644 index 0000000000..dc50f22613 --- /dev/null +++ b/flutter_module/lib/screens/settings/settings_screen.dart @@ -0,0 +1,218 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../providers/settings_provider.dart'; +import '../../providers/service_provider.dart'; + +class SettingsScreen extends ConsumerWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(settingsProvider); + final state = ref.watch(currentStateProvider); + final isRunning = !state.canToggle || state.isStarted; + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: settings.when( + data: (s) => ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + _sectionHeader(context, 'Service'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: DropdownButtonFormField( + initialValue: s.serviceMode, + decoration: const InputDecoration( + labelText: 'Service Mode', + prefixIcon: Icon(Icons.miscellaneous_services_rounded), + ), + items: const [ + DropdownMenuItem(value: 'vpn', child: Text('VPN')), + DropdownMenuItem(value: 'proxy', child: Text('Proxy Only')), + DropdownMenuItem( + value: 'transproxy', + child: Text('Transparent Proxy')), + ], + onChanged: isRunning + ? null + : (v) { + if (v != null) { + ref + .read(settingsProvider.notifier) + .setServiceMode(v); + } + }, + ), + ), + const SizedBox(height: 16), + _sectionHeader(context, 'Ports'), + _portTile( + context, + ref, + label: 'SOCKS5 Proxy Port', + value: s.portProxy, + icon: Icons.lan_outlined, + enabled: !isRunning, + onChanged: (port) => + ref.read(settingsProvider.notifier).setPortProxy(port), + ), + _portTile( + context, + ref, + label: 'Local DNS Port', + value: s.portLocalDns, + icon: Icons.dns_outlined, + enabled: !isRunning, + onChanged: (port) => + ref.read(settingsProvider.notifier).setPortLocalDns(port), + ), + if (s.serviceMode == 'transproxy') + _portTile( + context, + ref, + label: 'Transproxy Port', + value: s.portTransproxy, + icon: Icons.swap_horiz_rounded, + enabled: !isRunning, + onChanged: (port) => ref + .read(settingsProvider.notifier) + .setPortTransproxy(port), + ), + const SizedBox(height: 16), + _sectionHeader(context, 'System'), + SwitchListTile( + title: const Text('Persist Across Reboot'), + subtitle: const Text('Auto-connect on device startup'), + secondary: const Icon(Icons.restart_alt_rounded), + value: s.persistAcrossReboot, + onChanged: isRunning + ? null + : (v) => ref + .read(settingsProvider.notifier) + .setPersistAcrossReboot(v), + ), + SwitchListTile( + title: const Text('Direct Boot Aware'), + subtitle: const Text('Connect before device unlock'), + secondary: const Icon(Icons.lock_open_rounded), + value: s.directBootAware, + onChanged: isRunning + ? null + : (v) => ref + .read(settingsProvider.notifier) + .setDirectBootAware(v), + ), + if (isRunning) ...[ + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Card( + color: theme.colorScheme.tertiaryContainer, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.info_outline_rounded, + color: theme.colorScheme.onTertiaryContainer), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Stop the service to change settings.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onTertiaryContainer, + ), + ), + ), + ], + ), + ), + ), + ), + ], + ], + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center(child: Text('Error: $error')), + ), + ); + } + + Widget _sectionHeader(BuildContext context, String title) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + ); + } + + Widget _portTile( + BuildContext context, + WidgetRef ref, { + required String label, + required int value, + required IconData icon, + required bool enabled, + required ValueChanged onChanged, + }) { + return ListTile( + leading: Icon(icon), + title: Text(label), + trailing: Text( + value.toString(), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + enabled: enabled, + onTap: enabled + ? () => _showPortDialog(context, label, value, onChanged) + : null, + ); + } + + void _showPortDialog(BuildContext context, String label, int currentValue, + ValueChanged onChanged) { + final controller = TextEditingController(text: currentValue.toString()); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(label), + content: TextField( + controller: controller, + keyboardType: TextInputType.number, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Port', + hintText: '1-65535', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final port = int.tryParse(controller.text); + if (port != null && port >= 1 && port <= 65535) { + onChanged(port); + Navigator.of(context).pop(); + } + }, + child: const Text('OK'), + ), + ], + ), + ); + } +} diff --git a/flutter_module/lib/screens/subscriptions/subscriptions_screen.dart b/flutter_module/lib/screens/subscriptions/subscriptions_screen.dart new file mode 100644 index 0000000000..47a9f5984f --- /dev/null +++ b/flutter_module/lib/screens/subscriptions/subscriptions_screen.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final _subscriptionChannelProvider = Provider( + (ref) => const MethodChannel('com.github.shadowsocks/subscriptions'), +); + +final subscriptionsProvider = + AsyncNotifierProvider>( + SubscriptionsNotifier.new); + +class SubscriptionsNotifier extends AsyncNotifier> { + MethodChannel get _channel => ref.read(_subscriptionChannelProvider); + + @override + Future> build() async { + final result = await _channel.invokeListMethod('getSubscriptions'); + return result ?? []; + } + + Future add(String url) async { + await _channel.invokeMethod('addSubscription', {'url': url}); + state = await AsyncValue.guard(() => build()); + } + + Future remove(String url) async { + await _channel.invokeMethod('removeSubscription', {'url': url}); + state = await AsyncValue.guard(() => build()); + } + + Future updateAll() async { + await _channel.invokeMethod('updateSubscriptions'); + } +} + +class SubscriptionsScreen extends ConsumerWidget { + const SubscriptionsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final subs = ref.watch(subscriptionsProvider); + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('Subscriptions'), + actions: [ + IconButton( + icon: const Icon(Icons.sync_rounded), + tooltip: 'Update All', + onPressed: () { + ref.read(subscriptionsProvider.notifier).updateAll(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Updating subscriptions...')), + ); + }, + ), + ], + ), + body: subs.when( + data: (list) { + if (list.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.sync_disabled_rounded, + size: 64, + color: theme.colorScheme.onSurfaceVariant.withAlpha(77)), + const SizedBox(height: 16), + Text('No subscriptions', + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant)), + const SizedBox(height: 8), + Text('Add a subscription URL to auto-update profiles.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant + .withAlpha(153))), + ], + ), + ); + } + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: list.length, + itemBuilder: (context, index) { + final url = list[index]; + return Dismissible( + key: ValueKey(url), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 24), + color: theme.colorScheme.error, + child: Icon(Icons.delete_outline_rounded, + color: theme.colorScheme.onError), + ), + onDismissed: (_) { + ref.read(subscriptionsProvider.notifier).remove(url); + }, + child: ListTile( + leading: CircleAvatar( + backgroundColor: theme.colorScheme.primaryContainer, + child: Icon(Icons.link_rounded, + color: theme.colorScheme.onPrimaryContainer), + ), + title: Text(url, maxLines: 2, overflow: TextOverflow.ellipsis), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + ), + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center(child: Text('Error: $error')), + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _showAddDialog(context, ref), + child: const Icon(Icons.add_rounded), + ), + ); + } + + void _showAddDialog(BuildContext context, WidgetRef ref) { + final controller = TextEditingController(); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Add Subscription'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Subscription URL', + hintText: 'https://...', + prefixIcon: Icon(Icons.link_rounded), + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final url = controller.text.trim(); + if (url.isNotEmpty) { + ref.read(subscriptionsProvider.notifier).add(url); + Navigator.of(context).pop(); + } + }, + child: const Text('Add'), + ), + ], + ), + ); + } +} diff --git a/flutter_module/lib/theme/app_theme.dart b/flutter_module/lib/theme/app_theme.dart new file mode 100644 index 0000000000..ec64a913e4 --- /dev/null +++ b/flutter_module/lib/theme/app_theme.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + // Brand colors - Blue Grey palette with Green accent + static const _primaryBlueGrey = Color(0xFF607D8B); + static const _accentGreen = Color(0xFF00C853); + + static final _lightColorScheme = ColorScheme.fromSeed( + seedColor: _primaryBlueGrey, + primary: _primaryBlueGrey, + secondary: _accentGreen, + brightness: Brightness.light, + ); + + static final _darkColorScheme = ColorScheme.fromSeed( + seedColor: _primaryBlueGrey, + primary: const Color(0xFF90A4AE), + secondary: _accentGreen, + surface: const Color(0xFF1A1C1E), + brightness: Brightness.dark, + ); + + static ThemeData get light => _buildTheme(_lightColorScheme); + static ThemeData get dark => _buildTheme(_darkColorScheme); + + static ThemeData _buildTheme(ColorScheme colorScheme) { + final textTheme = ThemeData(colorScheme: colorScheme).textTheme; + + return ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + textTheme: textTheme, + appBarTheme: AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 2, + backgroundColor: colorScheme.surface, + foregroundColor: colorScheme.onSurface, + titleTextStyle: textTheme.titleLarge?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + cardTheme: CardThemeData( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: colorScheme.outlineVariant.withAlpha(128)), + ), + clipBehavior: Clip.antiAlias, + ), + floatingActionButtonTheme: FloatingActionButtonThemeData( + elevation: 2, + highlightElevation: 4, + backgroundColor: colorScheme.secondary, + foregroundColor: Colors.white, + shape: const CircleBorder(), + largeSizeConstraints: const BoxConstraints.tightFor( + width: 64, + height: 64, + ), + ), + navigationDrawerTheme: NavigationDrawerThemeData( + indicatorShape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(28), + ), + ), + listTileTheme: ListTileThemeData( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: colorScheme.surfaceContainerHighest.withAlpha(77), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.primary, width: 2), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + dialogTheme: DialogThemeData( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + ), + bottomSheetTheme: const BottomSheetThemeData( + showDragHandle: true, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + ), + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return colorScheme.secondary; + } + return null; + }), + trackColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return colorScheme.secondary.withAlpha(77); + } + return null; + }), + ), + dividerTheme: DividerThemeData( + color: colorScheme.outlineVariant.withAlpha(77), + space: 1, + ), + ); + } +} diff --git a/flutter_module/pubspec.lock b/flutter_module/pubspec.lock new file mode 100644 index 0000000000..f5e2474dc2 --- /dev/null +++ b/flutter_module/pubspec.lock @@ -0,0 +1,266 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.dev" + source: hosted + version: "14.8.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + url: "https://pub.dev" + source: hosted + version: "0.12.18" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + url: "https://pub.dev" + source: hosted + version: "0.7.9" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" +sdks: + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.22.0" diff --git a/flutter_module/pubspec.yaml b/flutter_module/pubspec.yaml new file mode 100644 index 0000000000..c5aab6c8b6 --- /dev/null +++ b/flutter_module/pubspec.yaml @@ -0,0 +1,27 @@ +name: flutter_module +description: Flutter UI for shadowsocks-android + +version: 1.0.0+1 + +environment: + sdk: ^3.11.0 + +dependencies: + flutter: + sdk: flutter + flutter_riverpod: ^2.6.1 + go_router: ^14.8.1 + qr_flutter: ^4.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true + + module: + androidX: true + androidPackage: com.github.shadowsocks.flutter_module + iosBundleIdentifier: com.github.shadowsocks.flutterModule diff --git a/flutter_module/test/widget_test.dart b/flutter_module/test/widget_test.dart new file mode 100644 index 0000000000..4948aad38c --- /dev/null +++ b/flutter_module/test/widget_test.dart @@ -0,0 +1,218 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_module/models/profile.dart'; +import 'package:flutter_module/models/service_state.dart'; +import 'package:flutter_module/models/traffic_stats.dart'; + +void main() { + group('Profile', () { + test('fromMap/toMap round-trip preserves all fields', () { + final map = { + 'id': 42, + 'name': 'My Server', + 'host': '192.168.1.1', + 'remotePort': 9999, + 'password': 'secret', + 'method': 'aes-256-gcm', + 'route': 'bypass-lan', + 'remoteDns': '8.8.8.8', + 'proxyApps': true, + 'bypass': true, + 'udpdns': true, + 'ipv6': true, + 'metered': true, + 'individual': 'com.example.app', + 'plugin': 'v2ray-plugin', + 'udpFallback': 3, + 'subscription': 1, + 'tx': 1000, + 'rx': 2000, + 'userOrder': 5, + }; + final profile = Profile.fromMap(map); + final result = profile.toMap(); + + expect(result['id'], 42); + expect(result['name'], 'My Server'); + expect(result['host'], '192.168.1.1'); + expect(result['remotePort'], 9999); + expect(result['password'], 'secret'); + expect(result['method'], 'aes-256-gcm'); + expect(result['route'], 'bypass-lan'); + expect(result['remoteDns'], '8.8.8.8'); + expect(result['proxyApps'], true); + expect(result['bypass'], true); + expect(result['udpdns'], true); + expect(result['ipv6'], true); + expect(result['metered'], true); + expect(result['individual'], 'com.example.app'); + expect(result['plugin'], 'v2ray-plugin'); + expect(result['udpFallback'], 3); + expect(result['subscription'], 1); + expect(result['tx'], 1000); + expect(result['rx'], 2000); + expect(result['userOrder'], 5); + }); + + test('fromMap applies defaults for missing fields', () { + final profile = Profile.fromMap({'id': 1}); + expect(profile.name, ''); + expect(profile.host, ''); + expect(profile.remotePort, 8388); + expect(profile.password, ''); + expect(profile.method, 'chacha20-ietf-poly1305'); + expect(profile.route, 'all'); + expect(profile.remoteDns, 'dns.google'); + expect(profile.proxyApps, false); + expect(profile.bypass, false); + expect(profile.plugin, isNull); + expect(profile.udpFallback, isNull); + }); + + test('displayName returns name when set', () { + final profile = Profile( + id: 1, + host: '1.2.3.4', + password: 'pw', + name: 'Tokyo', + ); + expect(profile.displayName, 'Tokyo'); + }); + + test('displayName falls back to host:port when name is empty', () { + final profile = Profile( + id: 1, + host: '1.2.3.4', + remotePort: 443, + password: 'pw', + ); + expect(profile.displayName, '1.2.3.4:443'); + }); + + test('encryptionMethods is non-empty and contains common ciphers', () { + expect(Profile.encryptionMethods, isNotEmpty); + expect(Profile.encryptionMethods, contains('aes-256-gcm')); + expect(Profile.encryptionMethods, contains('chacha20-ietf-poly1305')); + }); + + test('routeOptions contains expected keys', () { + expect(Profile.routeOptions, containsPair('all', 'All')); + expect(Profile.routeOptions, containsPair('bypass-lan', 'Bypass LAN')); + expect(Profile.routeOptions.length, 7); + }); + }); + + group('ServiceState', () { + test('fromValue returns correct enum for each value', () { + expect(ServiceState.fromValue(0), ServiceState.idle); + expect(ServiceState.fromValue(1), ServiceState.connecting); + expect(ServiceState.fromValue(2), ServiceState.connected); + expect(ServiceState.fromValue(3), ServiceState.stopping); + expect(ServiceState.fromValue(4), ServiceState.stopped); + }); + + test('fromValue defaults to idle for unknown values', () { + expect(ServiceState.fromValue(99), ServiceState.idle); + expect(ServiceState.fromValue(-1), ServiceState.idle); + }); + + test('isStarted is true only for connected', () { + expect(ServiceState.connected.isStarted, true); + expect(ServiceState.idle.isStarted, false); + expect(ServiceState.connecting.isStarted, false); + expect(ServiceState.stopping.isStarted, false); + expect(ServiceState.stopped.isStarted, false); + }); + + test('isBusy is true for connecting and stopping', () { + expect(ServiceState.connecting.isBusy, true); + expect(ServiceState.stopping.isBusy, true); + expect(ServiceState.idle.isBusy, false); + expect(ServiceState.connected.isBusy, false); + expect(ServiceState.stopped.isBusy, false); + }); + + test('canToggle is inverse of isBusy', () { + for (final state in ServiceState.values) { + expect(state.canToggle, !state.isBusy); + } + }); + }); + + group('ServiceStatus', () { + test('fromMap deserializes all fields', () { + final status = ServiceStatus.fromMap({ + 'state': 2, + 'profileName': 'Tokyo', + 'message': 'Connected', + }); + expect(status.state, ServiceState.connected); + expect(status.profileName, 'Tokyo'); + expect(status.message, 'Connected'); + }); + + test('fromMap handles missing optional fields', () { + final status = ServiceStatus.fromMap({'state': 0}); + expect(status.state, ServiceState.idle); + expect(status.profileName, isNull); + expect(status.message, isNull); + }); + + test('fromMap defaults state to idle when missing', () { + final status = ServiceStatus.fromMap({}); + expect(status.state, ServiceState.idle); + }); + }); + + group('TrafficStats', () { + test('fromMap deserializes all fields', () { + final stats = TrafficStats.fromMap({ + 'profileId': 1, + 'txRate': 100, + 'rxRate': 200, + 'txTotal': 1000, + 'rxTotal': 2000, + }); + expect(stats.profileId, 1); + expect(stats.txRate, 100); + expect(stats.rxRate, 200); + expect(stats.txTotal, 1000); + expect(stats.rxTotal, 2000); + }); + + test('fromMap defaults to zero for missing fields', () { + final stats = TrafficStats.fromMap({}); + expect(stats.profileId, 0); + expect(stats.txRate, 0); + expect(stats.rxRate, 0); + expect(stats.txTotal, 0); + expect(stats.rxTotal, 0); + }); + + test('formatBytes formats bytes', () { + expect(TrafficStats.formatBytes(0), '0 B'); + expect(TrafficStats.formatBytes(512), '512 B'); + expect(TrafficStats.formatBytes(1023), '1023 B'); + }); + + test('formatBytes formats kilobytes', () { + expect(TrafficStats.formatBytes(1024), '1.0 KB'); + expect(TrafficStats.formatBytes(1536), '1.5 KB'); + }); + + test('formatBytes formats megabytes', () { + expect(TrafficStats.formatBytes(1024 * 1024), '1.0 MB'); + expect(TrafficStats.formatBytes(1024 * 1024 * 5), '5.0 MB'); + }); + + test('formatBytes formats gigabytes', () { + expect(TrafficStats.formatBytes(1024 * 1024 * 1024), '1.00 GB'); + expect(TrafficStats.formatBytes(1024 * 1024 * 1024 * 2), '2.00 GB'); + }); + + test('formatSpeed appends /s', () { + expect(TrafficStats.formatSpeed(0), '0 B/s'); + expect(TrafficStats.formatSpeed(1024), '1.0 KB/s'); + expect(TrafficStats.formatSpeed(1024 * 1024), '1.0 MB/s'); + }); + }); +} diff --git a/gradle.properties b/gradle.properties index 59cae693da..1b34554358 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,17 +9,18 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -android.enableJetifier=true +android.enableJetifier=false android.enableR8.fullMode=true android.enableResourceOptimizations=false android.nonTransitiveRClass=false android.useAndroidX=true +flutter.hostAppProjectName=mobile # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true -org.gradle.jvmargs=-Xmx1536m -XX:+UseParallelGC +org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC # systemProp.http.proxyHost=127.0.0.1 # systemProp.http.proxyPort=1080 \ No newline at end of file diff --git a/mobile/build.gradle.kts b/mobile/build.gradle.kts index 4cd077fc63..ef882e4b20 100644 --- a/mobile/build.gradle.kts +++ b/mobile/build.gradle.kts @@ -28,6 +28,8 @@ dependencies { implementation(libs.locale.api) implementation(libs.preferencex.simplemenu) implementation(libs.zxing) + // Flutter module + implementation(project(":flutter")) testImplementation(libs.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.test.runner) diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index 2bbf07020d..5e5085e0a0 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -19,11 +19,14 @@ tools:ignore="MissingTvBanner"> + android:launchMode="singleTask" + android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" + android:hardwareAccelerated="true" + android:windowSoftInputMode="adjustResize"> diff --git a/mobile/src/main/java/com/github/shadowsocks/App.kt b/mobile/src/main/java/com/github/shadowsocks/App.kt index 13c58c9182..b935ee8b70 100644 --- a/mobile/src/main/java/com/github/shadowsocks/App.kt +++ b/mobile/src/main/java/com/github/shadowsocks/App.kt @@ -23,11 +23,12 @@ package com.github.shadowsocks import android.app.Application import android.content.res.Configuration import androidx.appcompat.app.AppCompatDelegate +import com.github.shadowsocks.flutter.FlutterBridgeActivity class App : Application() { override fun onCreate() { super.onCreate() - Core.init(this, MainActivity::class) + Core.init(this, FlutterBridgeActivity::class) AppCompatDelegate.setCompatVectorFromResourcesEnabled(true) } diff --git a/mobile/src/main/java/com/github/shadowsocks/flutter/AppListChannelHandler.kt b/mobile/src/main/java/com/github/shadowsocks/flutter/AppListChannelHandler.kt new file mode 100644 index 0000000000..2e6f42a2ca --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/flutter/AppListChannelHandler.kt @@ -0,0 +1,92 @@ +package com.github.shadowsocks.flutter + +import android.Manifest +import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import com.github.shadowsocks.preference.DataStore +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import java.io.ByteArrayOutputStream + +object AppListChannelHandler { + fun register(messenger: BinaryMessenger, context: Context) { + MethodChannel(messenger, "com.github.shadowsocks/applist").setMethodCallHandler { call, result -> + GlobalScope.launch(Dispatchers.Main) { + try { + when (call.method) { + "getApps" -> { + val pm = context.packageManager + val proxiedSet = DataStore.individual.split("\n").toSet() + val apps = pm.getInstalledApplications(PackageManager.GET_META_DATA) + .filter { ai -> + try { + pm.getPackageInfo(ai.packageName, PackageManager.GET_PERMISSIONS) + ?.requestedPermissions + ?.contains(Manifest.permission.INTERNET) == true + } catch (_: PackageManager.NameNotFoundException) { + false + } + } + .map { ai -> + mapOf( + "package" to ai.packageName, + "name" to (pm.getApplicationLabel(ai)?.toString() ?: ai.packageName), + "uid" to ai.uid, + "isProxied" to proxiedSet.contains(ai.packageName), + ) + } + result.success(apps) + } + "getAppIcon" -> { + val packageName = call.argument("package") ?: "" + val pm = context.packageManager + try { + val drawable = pm.getApplicationIcon(packageName) + val bitmap = if (drawable is BitmapDrawable) { + drawable.bitmap + } else { + val bmp = Bitmap.createBitmap(48, 48, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + drawable.setBounds(0, 0, 48, 48) + drawable.draw(canvas) + bmp + } + val stream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) + result.success(stream.toByteArray()) + } catch (_: PackageManager.NameNotFoundException) { + result.success(null) + } + } + "getProxyAppsConfig" -> { + result.success(mapOf( + "enabled" to DataStore.proxyApps, + "bypass" to DataStore.bypass, + "individual" to DataStore.individual, + )) + } + "setProxiedApps" -> { + @Suppress("UNCHECKED_CAST") + val packages = call.argument>("packages") ?: emptyList() + val bypass = call.argument("bypass") ?: false + DataStore.individual = packages.joinToString("\n") + DataStore.bypass = bypass + DataStore.proxyApps = packages.isNotEmpty() + result.success(null) + } + else -> result.notImplemented() + } + } catch (e: Exception) { + result.error("APPLIST_ERROR", e.message, null) + } + } + } + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/flutter/FlutterBridgeActivity.kt b/mobile/src/main/java/com/github/shadowsocks/flutter/FlutterBridgeActivity.kt new file mode 100644 index 0000000000..d680dde167 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/flutter/FlutterBridgeActivity.kt @@ -0,0 +1,215 @@ +package com.github.shadowsocks.flutter + +import android.app.Activity +import android.os.Bundle +import android.os.RemoteException +import androidx.activity.result.contract.ActivityResultContracts +import com.github.shadowsocks.Core +import com.github.shadowsocks.aidl.IShadowsocksService +import com.github.shadowsocks.aidl.ShadowsocksConnection +import com.github.shadowsocks.aidl.TrafficStats +import com.github.shadowsocks.bg.BaseService +import com.github.shadowsocks.plugin.PluginContract +import com.github.shadowsocks.plugin.PluginManager +import com.github.shadowsocks.preference.DataStore +import com.github.shadowsocks.utils.Key +import com.github.shadowsocks.utils.StartService +import io.flutter.embedding.android.FlutterFragmentActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodChannel + +class FlutterBridgeActivity : FlutterFragmentActivity(), ShadowsocksConnection.Callback { + + private val connection = ShadowsocksConnection(true) + private var state = BaseService.State.Idle + private var pendingResult: MethodChannel.Result? = null + private var pendingPluginResult: MethodChannel.Result? = null + + // Event sinks for streaming data to Flutter + var stateEventSink: EventChannel.EventSink? = null + var trafficEventSink: EventChannel.EventSink? = null + + private val connect = registerForActivityResult(StartService()) { denied -> + val result = pendingResult + pendingResult = null + if (denied) { + result?.success(false) + } else { + result?.success(true) + } + } + + private val configurePlugin = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { activityResult -> + val result = pendingPluginResult + pendingPluginResult = null + when (activityResult.resultCode) { + Activity.RESULT_OK -> { + val options = activityResult.data?.getStringExtra(PluginContract.EXTRA_OPTIONS) + result?.success(mapOf("status" to "ok", "options" to (options ?: ""))) + } + PluginContract.RESULT_FALLBACK -> { + result?.success(mapOf("status" to "fallback")) + } + else -> { + result?.success(mapOf("status" to "cancelled")) + } + } + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + val messenger = flutterEngine.dartExecutor.binaryMessenger + + // Service control channel + MethodChannel(messenger, "com.github.shadowsocks/service").setMethodCallHandler { call, result -> + when (call.method) { + "getState" -> result.success(state.ordinal) + "toggle" -> handleToggle(result) + "requestVpnPermission" -> { + pendingResult = result + connect.launch(null) + } + "configurePlugin" -> { + val pluginId = call.argument("pluginId") ?: "" + val options = call.argument("options") ?: "" + val intent = PluginManager.buildIntent(pluginId, PluginContract.ACTION_CONFIGURE) + if (intent.resolveActivity(packageManager) != null) { + pendingPluginResult = result + configurePlugin.launch( + intent.putExtra(PluginContract.EXTRA_OPTIONS, options) + ) + } else { + result.success(mapOf("status" to "fallback")) + } + } + "testConnection" -> result.success(null) // TODO: implement HttpsTest + else -> result.notImplemented() + } + } + + // State event channel + EventChannel(messenger, "com.github.shadowsocks/state").setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + stateEventSink = events + } + override fun onCancel(arguments: Any?) { + stateEventSink = null + } + } + ) + + // Traffic event channel + EventChannel(messenger, "com.github.shadowsocks/traffic").setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + trafficEventSink = events + } + override fun onCancel(arguments: Any?) { + trafficEventSink = null + } + } + ) + + // Profile channel + ProfileChannelHandler.register(messenger) + + // Settings channel + SettingsChannelHandler.register(messenger) + + // App list channel + AppListChannelHandler.register(messenger, this) + } + + private fun handleToggle(result: MethodChannel.Result) { + if (state.canStop) { + Core.stopService() + result.success(true) + } else { + pendingResult = result + connect.launch(null) + } + } + + // --- ShadowsocksConnection.Callback --- + + override fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) { + this.state = state + runOnUiThread { + stateEventSink?.success(mapOf( + "state" to state.ordinal, + "profileName" to profileName, + "message" to msg, + )) + } + } + + override fun trafficUpdated(profileId: Long, stats: TrafficStats) { + runOnUiThread { + trafficEventSink?.success(mapOf( + "profileId" to profileId, + "txRate" to stats.txRate, + "rxRate" to stats.rxRate, + "txTotal" to stats.txTotal, + "rxTotal" to stats.rxTotal, + )) + } + } + + override fun trafficPersisted(profileId: Long) { + // Traffic persisted, Flutter can refresh if needed + } + + override fun onServiceConnected(service: IShadowsocksService) { + val newState = try { + BaseService.State.entries[service.state] + } catch (_: RemoteException) { + BaseService.State.Idle + } + stateChanged(newState, null, null) + } + + override fun onServiceDisconnected() = stateChanged(BaseService.State.Idle, null, null) + + override fun onBinderDied() { + connection.disconnect(this) + connection.connect(this, this) + } + + // --- Lifecycle --- + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + connection.connect(this, this) + DataStore.publicStore.registerChangeListener(object : + com.github.shadowsocks.preference.OnPreferenceDataStoreChangeListener { + override fun onPreferenceDataStoreChanged( + store: androidx.preference.PreferenceDataStore, key: String + ) { + if (key == Key.serviceMode) { + connection.disconnect(this@FlutterBridgeActivity) + connection.connect(this@FlutterBridgeActivity, this@FlutterBridgeActivity) + } + } + }) + } + + override fun onStart() { + super.onStart() + connection.bandwidthTimeout = 500 + } + + override fun onStop() { + connection.bandwidthTimeout = 0 + super.onStop() + } + + override fun onDestroy() { + super.onDestroy() + connection.disconnect(this) + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/flutter/ProfileChannelHandler.kt b/mobile/src/main/java/com/github/shadowsocks/flutter/ProfileChannelHandler.kt new file mode 100644 index 0000000000..5c4e77eb5f --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/flutter/ProfileChannelHandler.kt @@ -0,0 +1,161 @@ +package com.github.shadowsocks.flutter + +import com.github.shadowsocks.Core +import com.github.shadowsocks.database.Profile +import com.github.shadowsocks.database.ProfileManager +import com.github.shadowsocks.plugin.PluginManager +import com.github.shadowsocks.preference.DataStore +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import java.io.ByteArrayInputStream + +object ProfileChannelHandler { + fun register(messenger: BinaryMessenger) { + MethodChannel(messenger, "com.github.shadowsocks/profiles").setMethodCallHandler { call, result -> + GlobalScope.launch(Dispatchers.Main) { + try { + when (call.method) { + "getProfiles" -> { + val profiles = ProfileManager.getActiveProfiles() ?: emptyList() + result.success(profiles.map { it.toFlutterMap() }) + } + "getProfile" -> { + val id = (call.argument("id") as Number).toLong() + val profile = ProfileManager.getProfile(id) + result.success(profile?.toFlutterMap()) + } + "createProfile" -> { + val profile = profileFromFlutterMap(call.arguments as Map<*, *>) + val created = ProfileManager.createProfile(profile) + result.success(created.toFlutterMap()) + } + "updateProfile" -> { + val map = call.arguments as Map<*, *> + val id = (map["id"] as Number).toLong() + val existing = ProfileManager.getProfile(id) + if (existing != null) { + updateProfileFromMap(existing, map) + ProfileManager.updateProfile(existing) + result.success(true) + } else { + result.success(false) + } + } + "deleteProfile" -> { + val id = (call.argument("id") as Number).toLong() + ProfileManager.delProfile(id) + result.success(true) + } + "selectProfile" -> { + val id = (call.argument("id") as Number).toLong() + Core.switchProfile(id) + result.success(null) + } + "getSelectedId" -> { + result.success(DataStore.profileId) + } + "importFromText" -> { + val text = call.argument("text") ?: "" + val profiles = Profile.findAllUrls(text).toList() + profiles.forEach { ProfileManager.createProfile(it) } + result.success(profiles.size) + } + "importFromJson" -> { + val json = call.argument("json") ?: "" + var count = 0 + Profile.parseJson(json) { + count++ + ProfileManager.createProfile(it) + } + result.success(count) + } + "exportToJson" -> { + val json = ProfileManager.serializeToJson() + result.success(json?.toString(2) ?: "[]") + } + "getProfileUri" -> { + val id = (call.argument("id") as Number).toLong() + val profile = ProfileManager.getProfile(id) + result.success(profile?.toUri()?.toString()) + } + "getPlugins" -> { + val plugins = PluginManager.fetchPlugins() + result.success(plugins.map { plugin -> + mapOf( + "id" to plugin.id, + "label" to plugin.label.toString(), + "packageName" to plugin.packageName, + "defaultConfig" to (plugin.defaultConfig ?: ""), + ) + }) + } + "reorderProfiles" -> { + @Suppress("UNCHECKED_CAST") + val order = call.argument>>("order") ?: emptyList() + for (item in order) { + val id = (item["id"] as Number).toLong() + val userOrder = (item["order"] as Number).toLong() + ProfileManager.getProfile(id)?.let { + it.userOrder = userOrder + ProfileManager.updateProfile(it) + } + } + result.success(null) + } + else -> result.notImplemented() + } + } catch (e: Exception) { + result.error("PROFILE_ERROR", e.message, null) + } + } + } + } + + private fun Profile.toFlutterMap(): Map = mapOf( + "id" to id, + "name" to (name ?: ""), + "host" to host, + "remotePort" to remotePort, + "password" to password, + "method" to method, + "route" to route, + "remoteDns" to remoteDns, + "proxyApps" to proxyApps, + "bypass" to bypass, + "udpdns" to udpdns, + "ipv6" to ipv6, + "metered" to metered, + "individual" to individual, + "plugin" to plugin, + "udpFallback" to udpFallback, + "subscription" to subscription.persistedValue, + "tx" to tx, + "rx" to rx, + "userOrder" to userOrder, + ) + + private fun profileFromFlutterMap(map: Map<*, *>): Profile = Profile().apply { + updateProfileFromMap(this, map) + } + + private fun updateProfileFromMap(profile: Profile, map: Map<*, *>) { + (map["name"] as? String)?.let { profile.name = it } + (map["host"] as? String)?.let { profile.host = it } + (map["remotePort"] as? Number)?.let { profile.remotePort = it.toInt() } + (map["password"] as? String)?.let { profile.password = it } + (map["method"] as? String)?.let { profile.method = it } + (map["route"] as? String)?.let { profile.route = it } + (map["remoteDns"] as? String)?.let { profile.remoteDns = it } + (map["proxyApps"] as? Boolean)?.let { profile.proxyApps = it } + (map["bypass"] as? Boolean)?.let { profile.bypass = it } + (map["udpdns"] as? Boolean)?.let { profile.udpdns = it } + (map["ipv6"] as? Boolean)?.let { profile.ipv6 = it } + (map["metered"] as? Boolean)?.let { profile.metered = it } + (map["individual"] as? String)?.let { profile.individual = it } + (map["plugin"] as? String)?.let { profile.plugin = it } + (map["udpFallback"] as? Number)?.let { profile.udpFallback = it.toLong() } + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/flutter/SettingsChannelHandler.kt b/mobile/src/main/java/com/github/shadowsocks/flutter/SettingsChannelHandler.kt new file mode 100644 index 0000000000..3d15e23c7a --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/flutter/SettingsChannelHandler.kt @@ -0,0 +1,79 @@ +package com.github.shadowsocks.flutter + +import com.github.shadowsocks.BootReceiver +import com.github.shadowsocks.Core +import com.github.shadowsocks.preference.DataStore +import com.github.shadowsocks.utils.DirectBoot +import com.github.shadowsocks.utils.Key +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodChannel + +object SettingsChannelHandler { + fun register(messenger: BinaryMessenger) { + MethodChannel(messenger, "com.github.shadowsocks/settings").setMethodCallHandler { call, result -> + try { + when (call.method) { + "getServiceMode" -> result.success(DataStore.serviceMode) + "setServiceMode" -> { + val mode = call.argument("mode") ?: Key.modeVpn + DataStore.publicStore.putString(Key.serviceMode, mode) + result.success(null) + } + "getPortProxy" -> result.success(DataStore.portProxy) + "setPortProxy" -> { + DataStore.portProxy = call.argument("port") ?: 1080 + result.success(null) + } + "getPortLocalDns" -> result.success(DataStore.portLocalDns) + "setPortLocalDns" -> { + DataStore.portLocalDns = call.argument("port") ?: 5450 + result.success(null) + } + "getPortTransproxy" -> result.success(DataStore.portTransproxy) + "setPortTransproxy" -> { + DataStore.portTransproxy = call.argument("port") ?: 8200 + result.success(null) + } + "getPersistAcrossReboot" -> result.success(DataStore.persistAcrossReboot) + "setPersistAcrossReboot" -> { + val value = call.argument("value") ?: false + DataStore.publicStore.putBoolean(Key.persistAcrossReboot, value) + BootReceiver.enabled = value + result.success(null) + } + "getDirectBootAware" -> result.success(DataStore.directBootAware) + "setDirectBootAware" -> { + val value = call.argument("value") ?: false + DataStore.publicStore.putBoolean(Key.directBootAware, value) + if (value) DirectBoot.update() else DirectBoot.clean() + result.success(null) + } + "getVersion" -> result.success(Core.packageInfo.versionName) + "getString" -> { + val key = call.argument("key") ?: "" + result.success(DataStore.publicStore.getString(key)) + } + "putString" -> { + val key = call.argument("key") ?: "" + val value = call.argument("value") ?: "" + DataStore.publicStore.putString(key, value) + result.success(null) + } + "getBool" -> { + val key = call.argument("key") ?: "" + result.success(DataStore.publicStore.getBoolean(key, false)) + } + "putBool" -> { + val key = call.argument("key") ?: "" + val value = call.argument("value") ?: false + DataStore.publicStore.putBoolean(key, value) + result.success(null) + } + else -> result.notImplemented() + } + } catch (e: Exception) { + result.error("SETTINGS_ERROR", e.message, null) + } + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index ef7771c079..8c10de58d2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,4 +1,30 @@ +pluginManagement { + val flutterSdkPath: String? = run { + val properties = java.util.Properties() + val localPropertiesFile = file("local.properties") + if (localPropertiesFile.exists()) { + localPropertiesFile.inputStream().use { properties.load(it) } + } + properties.getProperty("flutter.sdk") + } + + if (flutterSdkPath != null) { + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + } + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } + include(":core", ":plugin", ":mobile", ":tv") + +// Flutter module +include(":flutter") +project(":flutter").projectDir = file("flutter_module/.android/Flutter") diff --git a/test-e2e.sh b/test-e2e.sh index ee12f9c750..73f5553de3 100755 --- a/test-e2e.sh +++ b/test-e2e.sh @@ -129,7 +129,7 @@ info "Step 5: Configuring profile..." # ensureNotEmpty() creates a default profile (id=1) and sets profileId=1. # serviceMode defaults to "vpn". info " Launching app to initialize databases..." -"$ADB" shell am start -W -n "$PKG/.MainActivity" +"$ADB" shell am start -W -n "$PKG/.flutter.FlutterBridgeActivity" sleep 8 screenshot "01_init" # Force a checkpoint to flush WAL into main database file @@ -185,7 +185,7 @@ info " Profile configuration done." info "Step 6: Enabling VPN..." # Launch the app -"$ADB" shell am start -W -n "$PKG/.MainActivity" +"$ADB" shell am start -W -n "$PKG/.flutter.FlutterBridgeActivity" sleep 3 screenshot "02_app_launched"