From bdb8991431e6a40796a5af9251464d6fee13ae42 Mon Sep 17 00:00:00 2001 From: Max Lv Date: Tue, 17 Feb 2026 15:23:34 +0800 Subject: [PATCH 1/7] Replace native Android UI with Flutter add-to-app module Introduce flutter_module/ with Material 3 UI (Riverpod + GoRouter) and Kotlin platform channel bridge layer. The core/ VPN service, AIDL IPC, Room database, and Rust/JNI binaries remain untouched. - FlutterBridgeActivity replaces MainActivity as launcher - 8 screens: home, profile config, settings, subscriptions, custom rules, scanner, app manager, about - Platform channels for profiles CRUD, service control, traffic stats, settings, and per-app proxy - Selected profile highlighted with tinted background, colored border, and check icon - minSdk bumped from 23 to 24 (Flutter requirement) - Gradle heap increased to 4g; Jetifier disabled Co-Authored-By: Claude Opus 4.6 --- buildSrc/src/main/kotlin/Helpers.kt | 2 +- flutter_module/.gitignore | 48 +++ flutter_module/.metadata | 10 + flutter_module/analysis_options.yaml | 4 + flutter_module/lib/app.dart | 70 ++++ .../lib/channels/profile_channel.dart | 73 ++++ .../lib/channels/service_channel.dart | 32 ++ .../lib/channels/settings_channel.dart | 73 ++++ .../lib/channels/traffic_channel.dart | 12 + flutter_module/lib/main.dart | 12 + flutter_module/lib/models/profile.dart | 171 +++++++++ flutter_module/lib/models/service_state.dart | 41 +++ flutter_module/lib/models/traffic_stats.dart | 38 ++ .../lib/providers/profiles_provider.dart | 77 ++++ .../lib/providers/service_provider.dart | 28 ++ .../lib/providers/settings_provider.dart | 96 +++++ .../lib/providers/traffic_provider.dart | 11 + .../lib/screens/about/about_screen.dart | 116 ++++++ .../app_manager/app_manager_screen.dart | 213 +++++++++++ .../custom_rules/custom_rules_screen.dart | 204 +++++++++++ .../lib/screens/home/home_screen.dart | 338 ++++++++++++++++++ .../screens/home/widgets/profile_card.dart | 180 ++++++++++ .../lib/screens/home/widgets/service_fab.dart | 78 ++++ .../lib/screens/home/widgets/stats_bar.dart | 98 +++++ .../profile_config/profile_config_screen.dart | 336 +++++++++++++++++ .../lib/screens/scanner/scanner_screen.dart | 76 ++++ .../lib/screens/settings/settings_screen.dart | 218 +++++++++++ .../subscriptions/subscriptions_screen.dart | 161 +++++++++ flutter_module/lib/theme/app_theme.dart | 120 +++++++ flutter_module/pubspec.lock | 266 ++++++++++++++ flutter_module/pubspec.yaml | 27 ++ flutter_module/test/widget_test.dart | 12 + gradle.properties | 5 +- mobile/build.gradle.kts | 2 + mobile/src/main/AndroidManifest.xml | 7 +- .../main/java/com/github/shadowsocks/App.kt | 3 +- .../flutter/AppListChannelHandler.kt | 92 +++++ .../flutter/FlutterBridgeActivity.kt | 178 +++++++++ .../flutter/ProfileChannelHandler.kt | 149 ++++++++ .../flutter/SettingsChannelHandler.kt | 79 ++++ settings.gradle.kts | 26 ++ 41 files changed, 3776 insertions(+), 6 deletions(-) create mode 100644 flutter_module/.gitignore create mode 100644 flutter_module/.metadata create mode 100644 flutter_module/analysis_options.yaml create mode 100644 flutter_module/lib/app.dart create mode 100644 flutter_module/lib/channels/profile_channel.dart create mode 100644 flutter_module/lib/channels/service_channel.dart create mode 100644 flutter_module/lib/channels/settings_channel.dart create mode 100644 flutter_module/lib/channels/traffic_channel.dart create mode 100644 flutter_module/lib/main.dart create mode 100644 flutter_module/lib/models/profile.dart create mode 100644 flutter_module/lib/models/service_state.dart create mode 100644 flutter_module/lib/models/traffic_stats.dart create mode 100644 flutter_module/lib/providers/profiles_provider.dart create mode 100644 flutter_module/lib/providers/service_provider.dart create mode 100644 flutter_module/lib/providers/settings_provider.dart create mode 100644 flutter_module/lib/providers/traffic_provider.dart create mode 100644 flutter_module/lib/screens/about/about_screen.dart create mode 100644 flutter_module/lib/screens/app_manager/app_manager_screen.dart create mode 100644 flutter_module/lib/screens/custom_rules/custom_rules_screen.dart create mode 100644 flutter_module/lib/screens/home/home_screen.dart create mode 100644 flutter_module/lib/screens/home/widgets/profile_card.dart create mode 100644 flutter_module/lib/screens/home/widgets/service_fab.dart create mode 100644 flutter_module/lib/screens/home/widgets/stats_bar.dart create mode 100644 flutter_module/lib/screens/profile_config/profile_config_screen.dart create mode 100644 flutter_module/lib/screens/scanner/scanner_screen.dart create mode 100644 flutter_module/lib/screens/settings/settings_screen.dart create mode 100644 flutter_module/lib/screens/subscriptions/subscriptions_screen.dart create mode 100644 flutter_module/lib/theme/app_theme.dart create mode 100644 flutter_module/pubspec.lock create mode 100644 flutter_module/pubspec.yaml create mode 100644 flutter_module/test/widget_test.dart create mode 100644 mobile/src/main/java/com/github/shadowsocks/flutter/AppListChannelHandler.kt create mode 100644 mobile/src/main/java/com/github/shadowsocks/flutter/FlutterBridgeActivity.kt create mode 100644 mobile/src/main/java/com/github/shadowsocks/flutter/ProfileChannelHandler.kt create mode 100644 mobile/src/main/java/com/github/shadowsocks/flutter/SettingsChannelHandler.kt 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..5d88593fb3 --- /dev/null +++ b/flutter_module/.gitignore @@ -0,0 +1,48 @@ +.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/ +.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..4d4fe1fd0b --- /dev/null +++ b/flutter_module/lib/channels/service_channel.dart @@ -0,0 +1,32 @@ +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'); + } + + 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..688b6f0159 --- /dev/null +++ b/flutter_module/lib/screens/app_manager/app_manager_screen.dart @@ -0,0 +1,213 @@ +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, + )); + } + } + + 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), + ), + ), + 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..6351abcfa8 --- /dev/null +++ b/flutter_module/lib/screens/profile_config/profile_config_screen.dart @@ -0,0 +1,336 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../models/profile.dart'; +import '../../providers/profiles_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 _ProfileConfigScreenState extends ConsumerState { + final _formKey = GlobalKey(); + late TextEditingController _nameCtrl; + late TextEditingController _hostCtrl; + late TextEditingController _portCtrl; + late TextEditingController _passwordCtrl; + late TextEditingController _remoteDnsCtrl; + String _method = 'chacha20-ietf-poly1305'; + String _route = 'all'; + 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'); + _loadProfile(); + } + + Future _loadProfile() async { + if (!widget.isNew) { + final channel = ref.read(profileChannelProvider); + 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; + }); + } + } + setState(() => _loading = false); + } + + @override + void dispose() { + _nameCtrl.dispose(); + _hostCtrl.dispose(); + _portCtrl.dispose(); + _passwordCtrl.dispose(); + _remoteDnsCtrl.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, + }; + + 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('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, + ), + ); + } + + 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..92a53800de --- /dev/null +++ b/flutter_module/test/widget_test.dart @@ -0,0 +1,12 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_module/app.dart'; + +void main() { + testWidgets('App renders', (WidgetTester tester) async { + await tester.pumpWidget( + const ProviderScope(child: ShadowsocksApp()), + ); + expect(find.text('Shadowsocks'), findsWidgets); + }); +} 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..b16f59dbd3 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/flutter/FlutterBridgeActivity.kt @@ -0,0 +1,178 @@ +package com.github.shadowsocks.flutter + +import android.os.Bundle +import android.os.RemoteException +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.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 + + // 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) + } + } + + 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) + } + "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..558c345d12 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/flutter/ProfileChannelHandler.kt @@ -0,0 +1,149 @@ +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.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()) + } + "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") From 6f91b83e096e53586a5fd022eaa9243aa47af51a Mon Sep 17 00:00:00 2001 From: Max Lv Date: Tue, 17 Feb 2026 15:24:56 +0800 Subject: [PATCH 2/7] Add bypass mode toggle to per-app proxy screen Adds a SwitchListTile that toggles between proxy mode (only selected apps use the proxy) and bypass mode (selected apps bypass the proxy). The setting is persisted via the existing applist platform channel. Co-Authored-By: Claude Opus 4.6 --- .../app_manager/app_manager_screen.dart | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/flutter_module/lib/screens/app_manager/app_manager_screen.dart b/flutter_module/lib/screens/app_manager/app_manager_screen.dart index 688b6f0159..80558402f8 100644 --- a/flutter_module/lib/screens/app_manager/app_manager_screen.dart +++ b/flutter_module/lib/screens/app_manager/app_manager_screen.dart @@ -77,6 +77,16 @@ class _AppListNotifier extends AsyncNotifier<_AppListConfig> { } } + 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; @@ -132,6 +142,21 @@ class _AppManagerScreenState extends ConsumerState { 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) { From 7674305c82433cda82ffc47301bfe8ffcccff619 Mon Sep 17 00:00:00 2001 From: Max Lv Date: Tue, 17 Feb 2026 15:27:54 +0800 Subject: [PATCH 3/7] Add plugin settings to profile config screen Adds Plugin section with two fields: - Plugin ID (e.g. v2ray-plugin, obfs-local) - Plugin Options (semicolon-separated key=value pairs) The plugin string is parsed on load (splitting at first semicolon) and reassembled on save. An empty plugin ID means no plugin is configured. Co-Authored-By: Claude Opus 4.6 --- .../profile_config/profile_config_screen.dart | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/flutter_module/lib/screens/profile_config/profile_config_screen.dart b/flutter_module/lib/screens/profile_config/profile_config_screen.dart index 6351abcfa8..a02b9a72b9 100644 --- a/flutter_module/lib/screens/profile_config/profile_config_screen.dart +++ b/flutter_module/lib/screens/profile_config/profile_config_screen.dart @@ -23,6 +23,8 @@ class _ProfileConfigScreenState extends ConsumerState { late TextEditingController _portCtrl; late TextEditingController _passwordCtrl; late TextEditingController _remoteDnsCtrl; + late TextEditingController _pluginCtrl; + late TextEditingController _pluginOptsCtrl; String _method = 'chacha20-ietf-poly1305'; String _route = 'all'; bool _ipv6 = false; @@ -39,6 +41,8 @@ class _ProfileConfigScreenState extends ConsumerState { _portCtrl = TextEditingController(text: '8388'); _passwordCtrl = TextEditingController(); _remoteDnsCtrl = TextEditingController(text: 'dns.google'); + _pluginCtrl = TextEditingController(); + _pluginOptsCtrl = TextEditingController(); _loadProfile(); } @@ -58,6 +62,7 @@ class _ProfileConfigScreenState extends ConsumerState { _ipv6 = profile.ipv6; _metered = profile.metered; _udpdns = profile.udpdns; + _parsePlugin(profile.plugin); }); } } @@ -71,6 +76,8 @@ class _ProfileConfigScreenState extends ConsumerState { _portCtrl.dispose(); _passwordCtrl.dispose(); _remoteDnsCtrl.dispose(); + _pluginCtrl.dispose(); + _pluginOptsCtrl.dispose(); super.dispose(); } @@ -114,6 +121,7 @@ class _ProfileConfigScreenState extends ConsumerState { 'ipv6': _ipv6, 'metered': _metered, 'udpdns': _udpdns, + 'plugin': _buildPluginString(), }; if (widget.isNew) { @@ -266,6 +274,27 @@ class _ProfileConfigScreenState extends ConsumerState { ), ), const SizedBox(height: 24), + _sectionHeader('Plugin'), + const SizedBox(height: 8), + TextFormField( + controller: _pluginCtrl, + decoration: const InputDecoration( + labelText: 'Plugin', + hintText: 'e.g. v2ray-plugin, obfs-local', + prefixIcon: Icon(Icons.extension_rounded), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _pluginOptsCtrl, + decoration: const InputDecoration( + labelText: 'Plugin Options', + hintText: 'key=value;key=value', + prefixIcon: Icon(Icons.tune_rounded), + ), + maxLines: null, + ), + const SizedBox(height: 24), _sectionHeader('Options'), const SizedBox(height: 8), _switchTile( @@ -323,6 +352,24 @@ class _ProfileConfigScreenState extends ConsumerState { ); } + void _parsePlugin(String? plugin) { + if (plugin == null || plugin.isEmpty) return; + final semicolon = plugin.indexOf(';'); + if (semicolon < 0) { + _pluginCtrl.text = plugin; + } else { + _pluginCtrl.text = plugin.substring(0, semicolon); + _pluginOptsCtrl.text = plugin.substring(semicolon + 1); + } + } + + String _buildPluginString() { + final id = _pluginCtrl.text.trim(); + if (id.isEmpty) return ''; + final opts = _pluginOptsCtrl.text.trim(); + return opts.isEmpty ? id : '$id;$opts'; + } + Widget _switchTile( String title, String subtitle, bool value, ValueChanged onChanged) { return SwitchListTile( From 1deb543ae9544bf8c4f4234edf09d9c1e4a6c42e Mon Sep 17 00:00:00 2001 From: Max Lv Date: Tue, 17 Feb 2026 15:51:09 +0800 Subject: [PATCH 4/7] Add plugin auto-discovery and native config activity launch Replace manual plugin text fields with a dropdown auto-populated from installed SIP003 plugins via PluginManager.fetchPlugins(). Add a configure button that launches the plugin's native configuration activity using ActivityResultContracts, returning updated options to the Flutter UI. Co-Authored-By: Claude Opus 4.6 --- .../lib/channels/service_channel.dart | 10 ++ .../profile_config/profile_config_screen.dart | 132 +++++++++++++++--- .../flutter/FlutterBridgeActivity.kt | 37 +++++ .../flutter/ProfileChannelHandler.kt | 12 ++ 4 files changed, 169 insertions(+), 22 deletions(-) diff --git a/flutter_module/lib/channels/service_channel.dart b/flutter_module/lib/channels/service_channel.dart index 4d4fe1fd0b..a21075bd58 100644 --- a/flutter_module/lib/channels/service_channel.dart +++ b/flutter_module/lib/channels/service_channel.dart @@ -24,6 +24,16 @@ class ServiceChannel { 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/screens/profile_config/profile_config_screen.dart b/flutter_module/lib/screens/profile_config/profile_config_screen.dart index a02b9a72b9..663a9d18e5 100644 --- a/flutter_module/lib/screens/profile_config/profile_config_screen.dart +++ b/flutter_module/lib/screens/profile_config/profile_config_screen.dart @@ -1,8 +1,10 @@ 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; @@ -16,6 +18,18 @@ class ProfileConfigScreen extends ConsumerStatefulWidget { _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; @@ -23,10 +37,11 @@ class _ProfileConfigScreenState extends ConsumerState { late TextEditingController _portCtrl; late TextEditingController _passwordCtrl; late TextEditingController _remoteDnsCtrl; - late TextEditingController _pluginCtrl; 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; @@ -41,14 +56,28 @@ class _ProfileConfigScreenState extends ConsumerState { _portCtrl = TextEditingController(text: '8388'); _passwordCtrl = TextEditingController(); _remoteDnsCtrl = TextEditingController(text: 'dns.google'); - _pluginCtrl = TextEditingController(); _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 channel = ref.read(profileChannelProvider); final profile = await channel.getProfile(int.parse(widget.profileId!)); if (profile != null && mounted) { setState(() { @@ -66,7 +95,7 @@ class _ProfileConfigScreenState extends ConsumerState { }); } } - setState(() => _loading = false); + if (mounted) setState(() => _loading = false); } @override @@ -76,7 +105,6 @@ class _ProfileConfigScreenState extends ConsumerState { _portCtrl.dispose(); _passwordCtrl.dispose(); _remoteDnsCtrl.dispose(); - _pluginCtrl.dispose(); _pluginOptsCtrl.dispose(); super.dispose(); } @@ -276,24 +304,69 @@ class _ProfileConfigScreenState extends ConsumerState { const SizedBox(height: 24), _sectionHeader('Plugin'), const SizedBox(height: 8), - TextFormField( - controller: _pluginCtrl, + DropdownButtonFormField( + value: _plugins.any((p) => p.id == _selectedPluginId) + ? _selectedPluginId + : '', decoration: const InputDecoration( labelText: 'Plugin', - hintText: 'e.g. v2ray-plugin, obfs-local', 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(); + }, ), - const SizedBox(height: 12), - TextFormField( - controller: _pluginOptsCtrl, - decoration: const InputDecoration( - labelText: 'Plugin Options', - hintText: 'key=value;key=value', - prefixIcon: Icon(Icons.tune_rounded), + 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(), + ), + ], ), - maxLines: null, - ), + ], const SizedBox(height: 24), _sectionHeader('Options'), const SizedBox(height: 8), @@ -352,22 +425,37 @@ class _ProfileConfigScreenState extends ConsumerState { ); } + 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) { - _pluginCtrl.text = plugin; + _selectedPluginId = plugin; } else { - _pluginCtrl.text = plugin.substring(0, semicolon); + _selectedPluginId = plugin.substring(0, semicolon); _pluginOptsCtrl.text = plugin.substring(semicolon + 1); } } String _buildPluginString() { - final id = _pluginCtrl.text.trim(); - if (id.isEmpty) return ''; + if (_selectedPluginId.isEmpty) return ''; final opts = _pluginOptsCtrl.text.trim(); - return opts.isEmpty ? id : '$id;$opts'; + return opts.isEmpty ? _selectedPluginId : '$_selectedPluginId;$opts'; } Widget _switchTile( diff --git a/mobile/src/main/java/com/github/shadowsocks/flutter/FlutterBridgeActivity.kt b/mobile/src/main/java/com/github/shadowsocks/flutter/FlutterBridgeActivity.kt index b16f59dbd3..d680dde167 100644 --- a/mobile/src/main/java/com/github/shadowsocks/flutter/FlutterBridgeActivity.kt +++ b/mobile/src/main/java/com/github/shadowsocks/flutter/FlutterBridgeActivity.kt @@ -1,12 +1,16 @@ 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 @@ -20,6 +24,7 @@ class FlutterBridgeActivity : FlutterFragmentActivity(), ShadowsocksConnection.C 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 @@ -35,6 +40,25 @@ class FlutterBridgeActivity : FlutterFragmentActivity(), ShadowsocksConnection.C } } + 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) @@ -49,6 +73,19 @@ class FlutterBridgeActivity : FlutterFragmentActivity(), ShadowsocksConnection.C 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() } diff --git a/mobile/src/main/java/com/github/shadowsocks/flutter/ProfileChannelHandler.kt b/mobile/src/main/java/com/github/shadowsocks/flutter/ProfileChannelHandler.kt index 558c345d12..5c4e77eb5f 100644 --- a/mobile/src/main/java/com/github/shadowsocks/flutter/ProfileChannelHandler.kt +++ b/mobile/src/main/java/com/github/shadowsocks/flutter/ProfileChannelHandler.kt @@ -3,6 +3,7 @@ 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 @@ -80,6 +81,17 @@ object ProfileChannelHandler { 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() From 17adb6670b197bbca0d01bcdf1e0bcba8586b0f9 Mon Sep 17 00:00:00 2001 From: Max Lv Date: Tue, 17 Feb 2026 16:18:22 +0800 Subject: [PATCH 5/7] Update CI and E2E tests for Flutter refactor Add Flutter SDK setup to CircleCI and GitHub Actions so Gradle builds succeed with the new flutter_module dependency. Replace broken widget test with 21 pure Dart model unit tests. Fix E2E test activity name to use FlutterBridgeActivity. Co-Authored-By: Claude Opus 4.6 --- .circleci/config.yml | 15 ++ .github/workflows/e2e-test.yml | 12 ++ flutter_module/test/widget_test.dart | 220 ++++++++++++++++++++++++++- test-e2e.sh | 4 +- 4 files changed, 242 insertions(+), 9 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 985bb39cc0..de55d03d02 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -21,6 +21,21 @@ 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 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/flutter_module/test/widget_test.dart b/flutter_module/test/widget_test.dart index 92a53800de..4948aad38c 100644 --- a/flutter_module/test/widget_test.dart +++ b/flutter_module/test/widget_test.dart @@ -1,12 +1,218 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_module/app.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() { - testWidgets('App renders', (WidgetTester tester) async { - await tester.pumpWidget( - const ProviderScope(child: ShadowsocksApp()), - ); - expect(find.text('Shadowsocks'), findsWidgets); + 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/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" From fe375d8bed97e7d379f0da65ac1a8267851ff00c Mon Sep 17 00:00:00 2001 From: Max Lv Date: Tue, 17 Feb 2026 16:23:09 +0800 Subject: [PATCH 6/7] Add screen_*.png and flutter_module/README.md to gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 +++ flutter_module/.gitignore | 1 + 2 files changed, 4 insertions(+) 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/flutter_module/.gitignore b/flutter_module/.gitignore index 5d88593fb3..5a091758dc 100644 --- a/flutter_module/.gitignore +++ b/flutter_module/.gitignore @@ -39,6 +39,7 @@ Icon? build/ .android/ .ios/ +README.md .flutter-plugins-dependencies # Symbolication related From 037eb3a8eccb09b2d828c53148c9ad577fea8651 Mon Sep 17 00:00:00 2001 From: Max Lv Date: Wed, 18 Feb 2026 15:09:52 +0800 Subject: [PATCH 7/7] Exclude OSS licenses tasks from CI build The Flutter library module (generated file) doesn't publish AndroidTest variants, causing the oss-licenses plugin to fail when resolving debugAndroidTestCompileClasspath. These tasks aren't needed for CI. Co-Authored-By: Claude Opus 4.6 --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index de55d03d02..bd152d1d1d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -39,7 +39,7 @@ jobs: - 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