diff --git a/assets/translations/en.i18n.json b/assets/translations/en.i18n.json index df37b87e68..382daee389 100644 --- a/assets/translations/en.i18n.json +++ b/assets/translations/en.i18n.json @@ -98,6 +98,12 @@ "info(rich)": "Made with ❤️ by Hiddify - ${tap_source(Open Source)} (${tap_license(License)})" }, "pages": { + "auth": { + "title": "Sign in", + "subtitle": "Sign in with Telegram to access the service", + "signIn": "Sign in with Telegram", + "signingIn": "Signing in..." + }, "home": { "title": "Home", "quickSettings": "Quick setting options" @@ -688,9 +694,18 @@ "reconnect": "Reconnect", "reconnectMsg": "Reconnecting for taking into account the changes..." }, - "errors": { - "unexpected": "Unexpected error", - "connection": { +"errors": { + "unexpected": "Unexpected error", + "auth": { + "unexpected": "Sign-in is temporarily unavailable, please try again later", + "notConfigured": "Sign-in is temporarily unavailable, please try again later", + "invalidToken": "Could not sign in, please try again", + "telegramLogin": "Could not sign in, please try again", + "accountBlocked": "Your account has been blocked, please contact support", + "rateLimited": "Too many attempts, please wait a minute", + "sessionExpired": "Session expired, please sign in again" + }, + "connection": { "unexpected": "Unexpected connection error", "timeout": "Connection timeout", "badResponse": "Bad response", diff --git a/assets/translations/ru.i18n.json b/assets/translations/ru.i18n.json index a0ac4ad4ae..52f1dd49a1 100644 --- a/assets/translations/ru.i18n.json +++ b/assets/translations/ru.i18n.json @@ -102,6 +102,12 @@ "info(rich)": "Сделано с ❤️ Hiddify - ${tap_source(Открытый исходный код)} (${tap_license(Лицензия)})" }, "pages": { + "auth": { + "title": "Вход в аккаунт", + "subtitle": "Войдите через Telegram, чтобы получить доступ к сервису", + "signIn": "Войти через Telegram", + "signingIn": "Вход..." + }, "home": { "title": "Главная", "quickSettings": "Опции быстрых настроек" @@ -708,6 +714,15 @@ "invalidUrl": "Неверный URL", "canceledByUser": "Отменено пользователем" }, + "auth": { + "unexpected": "Вход временно недоступен, попробуйте позже", + "notConfigured": "Вход временно недоступен, попробуйте позже", + "invalidToken": "Не удалось войти, попробуйте ещё раз", + "telegramLogin": "Не удалось войти, попробуйте ещё раз", + "accountBlocked": "Ваш аккаунт заблокирован, обратитесь в поддержку", + "rateLimited": "Слишком много попыток, подождите минуту", + "sessionExpired": "Сессия истекла, войдите снова" + }, "connectivity": { "unexpected": "Непредвиденный сбой", "missingVpnPermission": "Отсутствует разрешение на VPN", diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 5cfb9adb9d..a476fb85ab 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -15,6 +15,8 @@ import 'package:hiddify/core/preferences/general_preferences.dart'; import 'package:hiddify/core/preferences/preferences_migration.dart'; import 'package:hiddify/core/preferences/preferences_provider.dart'; import 'package:hiddify/features/app/widget/app.dart'; +import 'package:hiddify/features/auth/data/auth_data_providers.dart'; +import 'package:hiddify/features/auth/notifier/auth_notifier.dart'; import 'package:hiddify/features/auto_start/notifier/auto_start_notifier.dart'; import 'package:hiddify/features/chain/model/chain_enum.dart'; import 'package:hiddify/features/chain/notifier/chain_profile_notifier.dart'; @@ -121,6 +123,14 @@ Future lazyBootstrap(WidgetsBinding widgetsBinding, Environment env) async } } + // Restore a persisted auth session (tokens from secure storage) before the + // app builds so returning users land on the home screen instead of /login. + // The auth interceptor on the Cabinet Dio is installed here as well. + await _init("auth session", () async { + container.read(authInterceptorProvider); + await container.read(authNotifierProvider.notifier).restoreSession(); + }); + Logger.bootstrap.info("bootstrap took [${stopWatch.elapsedMilliseconds}ms]"); stopWatch.stop(); diff --git a/lib/core/directories/directories_provider.dart b/lib/core/directories/directories_provider.dart index f89cef6962..e36929953f 100644 --- a/lib/core/directories/directories_provider.dart +++ b/lib/core/directories/directories_provider.dart @@ -39,7 +39,7 @@ class AppDirectories extends _$AppDirectories with InfraLogger { final baseDir = await getApplicationSupportDirectory(); final workingDir = Platform.isAndroid ? await _getAndroidWorkingDirectory() : baseDir; final tempDir = await getTemporaryDirectory(); - dirs = (baseDir: baseDir, workingDir: workingDir!, tempDir: tempDir); + dirs = (baseDir: baseDir, workingDir: workingDir, tempDir: tempDir); } if (!dirs.baseDir.existsSync()) { diff --git a/lib/core/http_client/dio_http_client.dart b/lib/core/http_client/dio_http_client.dart index 3708162a47..c2ddc43d8a 100644 --- a/lib/core/http_client/dio_http_client.dart +++ b/lib/core/http_client/dio_http_client.dart @@ -10,7 +10,7 @@ import 'package:hiddify/utils/custom_loggers.dart'; class DioHttpClient with InfraLogger { final Map _dio = {}; DioHttpClient({required Duration timeout, required this.userAgent, required bool debug}) { - for (var mode in ["proxy", "direct", "both"]) { + for (final mode in ["proxy", "direct", "both"]) { _dio[mode] = Dio( BaseOptions( connectTimeout: timeout, diff --git a/lib/core/logger/logger_controller.dart b/lib/core/logger/logger_controller.dart index 3d43bd6866..26fe115196 100644 --- a/lib/core/logger/logger_controller.dart +++ b/lib/core/logger/logger_controller.dart @@ -1,9 +1,9 @@ import 'dart:io'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:hiddify/core/logger/custom_logger.dart'; import 'package:hiddify/utils/custom_loggers.dart'; import 'package:loggy/loggy.dart'; -import 'package:flutter/foundation.dart' show kIsWeb; class LoggerController extends LoggyPrinter with InfraLogger { LoggerController(this.consolePrinter, this.otherPrinters); diff --git a/lib/core/model/app_config.dart b/lib/core/model/app_config.dart new file mode 100644 index 0000000000..87692512ba --- /dev/null +++ b/lib/core/model/app_config.dart @@ -0,0 +1,35 @@ +/// Compile-time configuration for the Bedolaga Cabinet (Remnawave) backend. +/// +/// These values must be provided at build time via `--dart-define`, e.g.: +/// ``` +/// flutter run --dart-define=CABINET_URL=https://cabinet.example.com \ +/// --dart-define=TELEGRAM_OIDC_CLIENT_ID=123456789 +/// ``` +/// +/// Secrets/IDs are intentionally NOT hardcoded here. +class AppConfig { + const AppConfig._(); + + /// Base URL of the Cabinet API, e.g. `https://cabinet.example.com`. + static const String cabinetUrl = String.fromEnvironment("CABINET_URL"); + + /// Numeric Telegram bot ID used as OIDC client_id. + static const String telegramOidcClientId = String.fromEnvironment("TELEGRAM_OIDC_CLIENT_ID"); + + /// Path to the Telegram OIDC login endpoint. + static const String telegramOidcLoginPath = "/cabinet/auth/telegram/oidc"; + + /// Path to the refresh-token endpoint. + /// + /// If the backend exposes a different refresh path than the login endpoint, + /// provide it via `--dart-define=TELEGRAM_OIDC_REFRESH_PATH=...`. Otherwise it + /// falls back to the login endpoint (which per the documented protocol is used + /// for exchanging tokens). + static const String telegramOidcRefreshPath = String.fromEnvironment( + "TELEGRAM_OIDC_REFRESH_PATH", + defaultValue: "/cabinet/auth/telegram/oidc", + ); + + /// Convenience guard: whether the auth config is fully provided at build time. + static bool get isConfigured => cabinetUrl.isNotEmpty && telegramOidcClientId.isNotEmpty; +} \ No newline at end of file diff --git a/lib/core/router/deep_linking/url_protocol/windows_protocol.dart b/lib/core/router/deep_linking/url_protocol/windows_protocol.dart index 3fd0b00afe..1e71f554f4 100644 --- a/lib/core/router/deep_linking/url_protocol/windows_protocol.dart +++ b/lib/core/router/deep_linking/url_protocol/windows_protocol.dart @@ -50,7 +50,7 @@ class WindowsProtocolHandler extends ProtocolHandler { } String _sanitize(String value) { - value = value.replaceAll(r'%s', '%1').replaceAll(r'"', '\\"'); + value = value.replaceAll('%s', '%1').replaceAll('"', '\\"'); return '"$value"'; } } diff --git a/lib/core/router/go_router/refresh_listenable.dart b/lib/core/router/go_router/refresh_listenable.dart index 94d72959c1..00341c1ec2 100644 --- a/lib/core/router/go_router/refresh_listenable.dart +++ b/lib/core/router/go_router/refresh_listenable.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hiddify/core/preferences/general_preferences.dart'; import 'package:hiddify/core/router/deep_linking/my_app_links.dart'; +import 'package:hiddify/features/auth/notifier/auth_notifier.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; // For temporary storage of the link received from AppLinks. @@ -15,6 +16,9 @@ class RefreshListenable extends ChangeNotifier { } }); ref.listen(Preferences.introCompleted, (_, _) => notifyListeners()); + // Re-run router redirects when the auth state changes (e.g. login/logout/ + // force-logout), so the user is moved to/from /login automatically. + ref.listen(authNotifierProvider, (_, _) => notifyListeners()); } final Ref ref; } diff --git a/lib/core/router/go_router/routing_config_notifier.dart b/lib/core/router/go_router/routing_config_notifier.dart index fa3fba03b6..49641f5451 100644 --- a/lib/core/router/go_router/routing_config_notifier.dart +++ b/lib/core/router/go_router/routing_config_notifier.dart @@ -9,6 +9,8 @@ import 'package:hiddify/core/router/go_router/helper/active_breakpoint_notifier. import 'package:hiddify/core/router/go_router/helper/custom_transition.dart'; import 'package:hiddify/core/router/go_router/refresh_listenable.dart'; import 'package:hiddify/features/about/widget/about_page.dart'; +import 'package:hiddify/features/auth/notifier/auth_notifier.dart'; +import 'package:hiddify/features/auth/widgets/login_page.dart'; import 'package:hiddify/features/home/widget/home_page.dart'; import 'package:hiddify/features/intro/widget/intro_page.dart'; import 'package:hiddify/features/log/overview/logs_page.dart'; @@ -110,6 +112,30 @@ class RoutingConfigNotifier extends _$RoutingConfigNotifier { // Prevent showing chainOptions while hasAnyProfile == false return '/settings'; } + + // Auth gate: protect the main screens from unauthenticated users. + // While session restore is in flight, do not redirect yet (avoids a + // flash of the login page for returning users). + switch (ref.watch(authNotifierProvider)) { + case AuthLoading(): + // Keep the current location while the session is being restored. + break; + case AuthLoggedOut(): + if (state.matchedLocation != '/login' && state.matchedLocation != '/intro') { + return '/login'; + } + break; + case AuthLoggedIn(): + if (state.matchedLocation == '/login') { + return '/home'; + } + break; + case AuthError(): + if (state.matchedLocation != '/login' && state.matchedLocation != '/intro') { + return '/login'; + } + break; + } return null; }, routes: [ @@ -309,6 +335,7 @@ class RoutingConfigNotifier extends _$RoutingConfigNotifier { ], ), GoRoute(name: 'intro', path: '/intro', builder: (_, _) => const IntroPage()), + GoRoute(name: 'login', path: '/login', builder: (_, _) => const LoginPage()), ], ); } diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index a270878f55..777af688e1 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -24,7 +24,7 @@ class AppTheme { return ThemeData( useMaterial3: true, colorScheme: scheme, - scaffoldBackgroundColor: mode.trueBlack ? Colors.black : scheme.background, + scaffoldBackgroundColor: mode.trueBlack ? Colors.black : scheme.surface, fontFamily: fontFamily, extensions: const >{ConnectionButtonTheme.light}, ); diff --git a/lib/core/widget/skeleton_widget.dart b/lib/core/widget/skeleton_widget.dart index 5efd8b0761..cab0f2ed59 100644 --- a/lib/core/widget/skeleton_widget.dart +++ b/lib/core/widget/skeleton_widget.dart @@ -31,7 +31,7 @@ class Skeleton extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), shape: shape, - color: theme.hintColor.withOpacity(.16), + color: theme.hintColor.withValues(alpha: .16), ), ), ); diff --git a/lib/features/app_update/notifier/app_update_notifier.dart b/lib/features/app_update/notifier/app_update_notifier.dart index 01f340e6f2..e16639fbcd 100644 --- a/lib/features/app_update/notifier/app_update_notifier.dart +++ b/lib/features/app_update/notifier/app_update_notifier.dart @@ -1,8 +1,6 @@ -import 'package:flutter/foundation.dart'; import 'package:hiddify/core/app_info/app_info_provider.dart'; import 'package:hiddify/core/localization/locale_preferences.dart'; import 'package:hiddify/core/model/constants.dart'; -import 'package:hiddify/core/model/environment.dart'; import 'package:hiddify/core/preferences/preferences_provider.dart'; import 'package:hiddify/core/utils/preferences_utils.dart'; import 'package:hiddify/features/app_update/data/app_update_data_providers.dart'; @@ -31,7 +29,6 @@ Upgrader upgrader(Ref ref) => Upgrader( onMacOS: () => UpgraderAppcastStore(appcastURL: Constants.appCastUrl), onWeb: () => UpgraderAppcastStore(appcastURL: Constants.appCastUrl), ), - debugLogging: false && _debugUpgrader && kDebugMode, // durationUntilAlertAgain: const Duration(hours: 12), messages: UpgraderMessages(code: ref.watch(localePreferencesProvider).languageCode), ); diff --git a/lib/features/auth/data/auth_api_client.dart b/lib/features/auth/data/auth_api_client.dart new file mode 100644 index 0000000000..44636fcf6b --- /dev/null +++ b/lib/features/auth/data/auth_api_client.dart @@ -0,0 +1,119 @@ +import 'package:dio/dio.dart'; +import 'package:hiddify/core/model/app_config.dart'; +import 'package:hiddify/features/auth/domain/auth_failure.dart'; +import 'package:hiddify/features/auth/domain/auth_tokens.dart'; +import 'package:hiddify/utils/custom_loggers.dart'; + +/// Typed exceptions raised by [AuthApiClient]. +/// +/// These are deliberately separate from the raw [DioException] so the UI layer +/// can map them to user-friendly messages without parsing HTTP status codes. +sealed class AuthException implements Exception { + const AuthException(this.failure); + + final AuthFailure failure; + + @override + String toString() => '$runtimeType($failure)'; +} + +class AuthNotConfiguredException extends AuthException { + const AuthNotConfiguredException() : super(const AuthFailure.notConfigured()); +} + +class AuthInvalidTokenException extends AuthException { + const AuthInvalidTokenException() : super(const AuthFailure.invalidToken()); +} + +class AuthAccountBlockedException extends AuthException { + const AuthAccountBlockedException() : super(const AuthFailure.accountBlocked()); +} + +class AuthRateLimitedException extends AuthException { + const AuthRateLimitedException() : super(const AuthFailure.rateLimited()); +} + +class AuthUnexpectedException extends AuthException { + AuthUnexpectedException([Object? error, StackTrace? stackTrace]) + : super(AuthFailure.unexpected(error, stackTrace)); +} + +/// HTTP client for the Bedolaga Cabinet API (Remnawave). +/// +/// Uses a dedicated plain [Dio] instance — NOT the proxy-aware +/// [DioHttpClient] used for sing-box / subscription traffic. +class AuthApiClient with InfraLogger { + AuthApiClient({ + required Dio dio, + required String baseUrl, + }) : _dio = dio { + _dio.options.baseUrl = baseUrl; + _dio.options.connectTimeout = const Duration(seconds: 15); + _dio.options.receiveTimeout = const Duration(seconds: 15); + _dio.options.sendTimeout = const Duration(seconds: 15); + _dio.options.headers['Accept'] = 'application/json'; + _dio.options.headers['Content-Type'] = 'application/json'; + } + + final Dio _dio; + + /// Exchanges a Telegram OIDC `id_token` for a token pair. + Future loginWithTelegram(String idToken) async { + try { + final response = await _dio.post>( + AppConfig.telegramOidcLoginPath, + data: { + 'id_token': idToken, + 'campaign_slug': null, + 'referral_code': null, + }, + ); + return _parseTokens(response.data); + } on AuthException { + rethrow; + } on DioException catch (e) { + throw _mapDioException(e); + } + } + + /// Refreshes an expired `access_token` using the `refresh_token`. + Future refreshTokens(String refreshToken) async { + try { + final response = await _dio.post>( + AppConfig.telegramOidcRefreshPath, + data: { + 'refresh_token': refreshToken, + }, + ); + return _parseTokens(response.data); + } on AuthException { + rethrow; + } on DioException catch (e) { + throw _mapDioException(e); + } + } + + AuthTokens _parseTokens(Map? data) { + if (data == null) { + throw const AuthUnexpectedException(); + } + return AuthTokens.fromJson(data); + } + + AuthException _mapDioException(DioException e) { + final statusCode = e.response?.statusCode; + switch (statusCode) { + case 400: + // 400 == OIDC not configured on the backend (technical issue, not the user's). + return const AuthNotConfiguredException(); + case 401: + return const AuthInvalidTokenException(); + case 403: + return const AuthAccountBlockedException(); + case 429: + return const AuthRateLimitedException(); + default: + return AuthUnexpectedException(e.error, e.stackTrace); + } + } +} \ No newline at end of file diff --git a/lib/features/auth/data/auth_data_providers.dart b/lib/features/auth/data/auth_data_providers.dart new file mode 100644 index 0000000000..fa8b3d862b --- /dev/null +++ b/lib/features/auth/data/auth_data_providers.dart @@ -0,0 +1,71 @@ +import 'package:dio/dio.dart'; +import 'package:hiddify/core/model/app_config.dart'; +import 'package:hiddify/features/auth/data/auth_api_client.dart'; +import 'package:hiddify/features/auth/data/auth_interceptor.dart'; +import 'package:hiddify/features/auth/data/token_storage.dart'; +import 'package:hiddify/features/auth/domain/auth_tokens.dart'; +import 'package:hiddify/features/auth/notifier/auth_notifier.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'auth_data_providers.g.dart'; + +/// A dedicated plain [Dio] instance for the Cabinet API. +/// +/// Kept separate from the proxy-aware [DioHttpClient] used for sing-box / +/// subscription traffic, since Cabinet requests must NOT go through the local +/// proxy and need their own auth interceptor. +@Riverpod(keepAlive: true) +Dio cabinetDio(Ref ref) { + return Dio(BaseOptions(baseUrl: AppConfig.cabinetUrl)); +} + +@Riverpod(keepAlive: true) +TokenStorage tokenStorage(Ref ref) { + return TokenStorage(); +} + +@Riverpod(keepAlive: true) +AuthApiClient authApiClient(Ref ref) { + return AuthApiClient( + dio: ref.watch(cabinetDioProvider), + baseUrl: AppConfig.cabinetUrl, + ); +} + +/// Installs the auth interceptor on the Cabinet [Dio] instance so outgoing +/// requests automatically attach `Authorization: Bearer ` and +/// transparently refresh on 401. +@Riverpod(keepAlive: true) +void authInterceptorProvider(Ref ref) { + final dio = ref.watch(cabinetDioProvider); + final notifier = ref.watch(authNotifierProvider.notifier); + dio.interceptors.add( + AuthInterceptor( + getAccessToken: () async => (await ref.read(tokenStorageProvider).read())?.tokens.accessToken, + getRefreshToken: () async => (await ref.read(tokenStorageProvider).read())?.tokens.refreshToken, + refreshTokens: (refreshToken) async { + final tokens = await ref.read(authApiClientProvider).refreshTokens(refreshToken); + final storage = ref.read(tokenStorageProvider); + await storage.save(tokens: tokens, expiresAt: DateTime.now().add(Duration(seconds: tokens.expiresIn))); + return tokens; + }, + onSessionExpired: notifier.forceLogout, + ), + ); +} + +/// Convenience provider exposing the currently stored access token (if any), +/// so other parts of the app can check auth state without coupling to storage. +@Riverpod(keepAlive: true) +Future currentAccessToken(Ref ref) async { + final session = await ref.read(tokenStorageProvider).read(); + return session?.tokens.accessToken; +} + +/// Whether a persisted session currently exists (used by routing / bootstrap). +@Riverpod(keepAlive: true) +Future storedSession(Ref ref) async { + final session = await ref.read(tokenStorageProvider).read(); + return session?.tokens; +} \ No newline at end of file diff --git a/lib/features/auth/data/auth_interceptor.dart b/lib/features/auth/data/auth_interceptor.dart new file mode 100644 index 0000000000..31759f3be4 --- /dev/null +++ b/lib/features/auth/data/auth_interceptor.dart @@ -0,0 +1,99 @@ +import 'package:dio/dio.dart'; +import 'package:hiddify/features/auth/domain/auth_tokens.dart'; +import 'package:hiddify/utils/custom_loggers.dart'; + +/// Adds `Authorization: Bearer ` to outgoing Cabinet requests and +/// transparently refreshes the access token once when the backend returns 401. +/// +/// Refresh is attempted at most **once** per request to avoid infinite loops. +/// If refresh fails, [onSessionExpired] is invoked so the app can log the user +/// out and redirect to the login screen. +class AuthInterceptor extends Interceptor with InfraLogger { + AuthInterceptor({ + required this.getAccessToken, + required this.getRefreshToken, + required this.refreshTokens, + required this.onSessionExpired, + }); + + /// Reads the current access token (from secure storage). + final Future Function() getAccessToken; + + /// Reads the stored refresh token (from secure storage). + final Future Function() getRefreshToken; + + /// Refreshes the token pair using [refreshToken]. Implementations should + /// persist the new tokens before returning. + final Future Function(String refreshToken) refreshTokens; + + /// Called when the refresh fails (invalid/expired refresh token) so the app + /// can force-logout the user. + final void Function() onSessionExpired; + + static const _refreshAttemptHeader = 'X-Auth-Retry-Attempt'; + + @override + Future onRequest(RequestOptions options, RequestInterceptorHandler handler) async { + // A request retried after a refresh already carries a fresh token. + if (options.extra[_refreshAttemptHeader] == true) { + return handler.next(options); + } + final accessToken = await getAccessToken(); + if (accessToken != null && accessToken.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $accessToken'; + } + return handler.next(options); + } + + @override + Future onError(DioException err, ErrorInterceptorHandler handler) async { + final isAuthRequest = err.requestOptions.uri.path.contains('/auth/'); + final alreadyRetried = err.requestOptions.extra[_refreshAttemptHeader] == true; + + if (err.response?.statusCode == 401 && !isAuthRequest && !alreadyRetried) { + try { + final refreshToken = await getRefreshToken(); + if (refreshToken == null || refreshToken.isEmpty) { + onSessionExpired(); + return handler.reject(err); + } + final newTokens = await refreshTokens(refreshToken); + if (!newTokens.isValid) { + onSessionExpired(); + return handler.reject(err); + } + + // Retry the original request once with the new access token. + final opts = err.requestOptions; + opts.headers['Authorization'] = 'Bearer ${newTokens.accessToken}'; + opts.extra[_refreshAttemptHeader] = true; + final response = await _retry(opts); + return handler.resolve(response); + } catch (e) { + loggy.warning('token refresh failed, logging out', e); + onSessionExpired(); + return handler.reject(err); + } + } + return handler.next(err); + } + + /// Re-issues the request on a fresh [Dio] without the interceptor to avoid + /// recursion. The extra header prevents this path from re-triggering refresh. + Future> _retry(RequestOptions opts) async { + final dio = Dio(); + return dio.request( + opts.path, + data: opts.data, + queryParameters: opts.queryParameters, + options: Options( + method: opts.method, + headers: opts.headers, + extra: opts.extra, + responseType: opts.responseType, + contentType: opts.contentType, + validateStatus: (_) => true, + )..baseUrl = opts.baseUrl, + ); + } +} \ No newline at end of file diff --git a/lib/features/auth/data/token_storage.dart b/lib/features/auth/data/token_storage.dart new file mode 100644 index 0000000000..2e0b6a8819 --- /dev/null +++ b/lib/features/auth/data/token_storage.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:hiddify/features/auth/domain/auth_tokens.dart'; +import 'package:hiddify/features/auth/domain/auth_user.dart'; +import 'package:hiddify/utils/custom_loggers.dart'; + +/// A small typed wrapper around [FlutterSecureStorage] for persisting tokens. +/// +/// Centralizes all secure-storage access so call sites never touch the +/// underlying plugin directly. Access/refresh tokens are persisted in the +/// platform's secure storage (Keychain / Keystore / encrypted file), NOT in +/// plain text on disk. +class TokenStorage with InfraLogger { + TokenStorage({FlutterSecureStorage? storage}) : _storage = storage ?? const FlutterSecureStorage(); + + final FlutterSecureStorage _storage; + + static const _accessTokenKey = 'auth_access_token'; + static const _refreshTokenKey = 'auth_refresh_token'; + static const _expiresAtKey = 'auth_expires_at'; + static const _userKey = 'auth_user'; + + /// Persists the given tokens. [expiresAt] is a concrete timestamp after + /// which [accessToken] is considered stale. + Future save({required AuthTokens tokens, required DateTime expiresAt}) async { + await _storage.write(key: _accessTokenKey, value: tokens.accessToken); + await _storage.write(key: _refreshTokenKey, value: tokens.refreshToken); + await _storage.write(key: _expiresAtKey, value: expiresAt.toIso8601String()); + final user = tokens.user; + if (user != null) { + await _storage.write(key: _userKey, value: jsonEncode(user.toJson())); + } + } + + /// Reads the persisted session, if any. Returns `null` when no session exists + /// or the stored data is corrupt. + Future read() async { + try { + final accessToken = await _storage.read(key: _accessTokenKey); + final refreshToken = await _storage.read(key: _refreshTokenKey); + if (accessToken == null || accessToken.isEmpty || refreshToken == null || refreshToken.isEmpty) { + return null; + } + final expiresAtRaw = await _storage.read(key: _expiresAtKey); + final userRaw = await _storage.read(key: _userKey); + return StoredSession( + tokens: AuthTokens( + accessToken: accessToken, + refreshToken: refreshToken, + expiresIn: 0, + user: userRaw == null ? null : AuthUser.fromJson(jsonDecode(userRaw) as Map), + ), + expiresAt: expiresAtRaw == null ? null : DateTime.tryParse(expiresAtRaw), + ); + } catch (e) { + loggy.warning('failed to read stored session', e); + return null; + } + } + + /// Clears the persisted session. + Future clear() async { + await _storage.delete(key: _accessTokenKey); + await _storage.delete(key: _refreshTokenKey); + await _storage.delete(key: _expiresAtKey); + await _storage.delete(key: _userKey); + } +} + +/// A persisted session: the token pair plus the moment the access token expires. +class StoredSession { + const StoredSession({required this.tokens, required this.expiresAt}); + + final AuthTokens tokens; + final DateTime? expiresAt; + + bool get hasNotExpiredAccess => expiresAt != null && DateTime.now().isBefore(expiresAt!); + bool get hasValidRefreshToken => tokens.refreshToken.isNotEmpty; +} \ No newline at end of file diff --git a/lib/features/auth/domain/auth_failure.dart b/lib/features/auth/domain/auth_failure.dart new file mode 100644 index 0000000000..32a3f8a34a --- /dev/null +++ b/lib/features/auth/domain/auth_failure.dart @@ -0,0 +1,37 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/failures.dart'; + +part 'auth_failure.freezed.dart'; + +@freezed +sealed class AuthFailure with _$AuthFailure, Failure { + const AuthFailure._(); + + @With() + const factory AuthFailure.unexpected([Object? error, StackTrace? stackTrace]) = AuthUnexpectedFailure; + + const factory AuthFailure.notConfigured() = AuthNotConfiguredFailure; + + const factory AuthFailure.invalidToken() = AuthInvalidTokenFailure; + + @With() + const factory AuthFailure.accountBlocked() = AuthAccountBlockedFailure; + + @With() + const factory AuthFailure.rateLimited() = AuthRateLimitedFailure; + + const factory AuthFailure.sessionExpired() = AuthSessionExpiredFailure; + + @override + ({String type, String? message}) present(TranslationsEn t) { + return switch (this) { + AuthUnexpectedFailure() => (type: t.errors.auth.unexpected, message: null), + AuthNotConfiguredFailure() => (type: t.errors.auth.notConfigured, message: null), + AuthInvalidTokenFailure() => (type: t.errors.auth.invalidToken, message: null), + AuthAccountBlockedFailure() => (type: t.errors.auth.accountBlocked, message: null), + AuthRateLimitedFailure() => (type: t.errors.auth.rateLimited, message: null), + AuthSessionExpiredFailure() => (type: t.errors.auth.sessionExpired, message: null), + }; + } +} \ No newline at end of file diff --git a/lib/features/auth/domain/auth_tokens.dart b/lib/features/auth/domain/auth_tokens.dart new file mode 100644 index 0000000000..c1a07dc4cf --- /dev/null +++ b/lib/features/auth/domain/auth_tokens.dart @@ -0,0 +1,23 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/features/auth/domain/auth_user.dart'; + +part 'auth_tokens.freezed.dart'; +part 'auth_tokens.g.dart'; + +/// Token pair and (optionally) the authenticated user returned by the +/// Bedolaga Cabinet API. +@freezed +class AuthTokens with _$AuthTokens { + const AuthTokens._(); + + const factory AuthTokens({ + required String accessToken, + required String refreshToken, + required int expiresIn, + AuthUser? user, + }) = _AuthTokens; + + factory AuthTokens.fromJson(Map json) => _$AuthTokensFromJson(json); + + bool get isValid => accessToken.isNotEmpty && refreshToken.isNotEmpty; +} \ No newline at end of file diff --git a/lib/features/auth/domain/auth_user.dart b/lib/features/auth/domain/auth_user.dart new file mode 100644 index 0000000000..0d992e690c --- /dev/null +++ b/lib/features/auth/domain/auth_user.dart @@ -0,0 +1,21 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'auth_user.freezed.dart'; +part 'auth_user.g.dart'; + +/// A user authenticated via the Bedolaga Cabinet API. +@freezed +class AuthUser with _$AuthUser { + const AuthUser._(); + + const factory AuthUser({ + required int id, + required int telegramId, + required String username, + String? firstName, + String? email, + @Default(0) int balanceKopeks, + }) = _AuthUser; + + factory AuthUser.fromJson(Map json) => _$AuthUserFromJson(json); +} \ No newline at end of file diff --git a/lib/features/auth/notifier/auth_notifier.dart b/lib/features/auth/notifier/auth_notifier.dart new file mode 100644 index 0000000000..fa4bf22124 --- /dev/null +++ b/lib/features/auth/notifier/auth_notifier.dart @@ -0,0 +1,129 @@ +import 'package:hiddify/core/model/app_config.dart'; +import 'package:hiddify/features/auth/data/auth_api_client.dart'; +import 'package:hiddify/features/auth/data/auth_data_providers.dart'; +import 'package:hiddify/features/auth/data/token_storage.dart'; +import 'package:hiddify/features/auth/domain/auth_failure.dart'; +import 'package:hiddify/features/auth/domain/auth_user.dart'; +import 'package:hiddify/utils/custom_loggers.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'auth_notifier.g.dart'; + +/// Authentication state exposed to the UI / router. +sealed class AuthState { + const AuthState(); +} + +class AuthLoggedOut extends AuthState { + const AuthLoggedOut(); +} + +class AuthLoading extends AuthState { + const AuthLoading(); +} + +class AuthLoggedIn extends AuthState { + const AuthLoggedIn(this.user); + final AuthUser user; +} + +class AuthError extends AuthState { + const AuthError(this.failure); + final AuthFailure failure; +} + +@Riverpod(keepAlive: true) +class AuthNotifier extends _$AuthNotifier with AppLogger { + AuthApiClient get _apiClient => ref.read(authApiClientProvider); + TokenStorage get _tokenStorage => ref.read(tokenStorageProvider); + + @override + AuthState build() { + return const AuthLoggedOut(); + } + + /// Restores a persisted session at app startup. + /// + /// If a valid access token exists (or at least a refresh token), the user is + /// considered logged in without going through the login UI. + Future restoreSession() async { + if (state is AuthLoggedIn || state is AuthLoading) return; + state = const AuthLoading(); + + final session = await _tokenStorage.read(); + if (session == null) { + state = const AuthLoggedOut(); + return; + } + + // Prefer a still-valid access token; otherwise attempt a refresh if the + // access token is expired but a refresh token exists. + if (session.hasNotExpiredAccess || !session.hasValidRefreshToken) { + final user = session.tokens.user; + if (user != null) { + state = AuthLoggedIn(user); + } else { + state = const AuthLoggedOut(); + } + return; + } + + // Access token expired but refresh token is present — try to refresh. + try { + final tokens = await _apiClient.refreshTokens(session.tokens.refreshToken); + await _tokenStorage.save(tokens: tokens, expiresAt: DateTime.now().add(Duration(seconds: tokens.expiresIn))); + final user = tokens.user ?? session.tokens.user; + if (user != null) { + state = AuthLoggedIn(user); + } else { + state = const AuthLoggedOut(); + } + } on AuthException catch (e) { + loggy.warning('failed to restore session', e); + await _tokenStorage.clear(); + state = const AuthLoggedOut(); + } catch (e) { + loggy.warning('unexpected error restoring session', e); + await _tokenStorage.clear(); + state = const AuthLoggedOut(); + } + } + + /// Performs a Telegram OIDC login using the given [idToken]. + Future loginWithTelegram(String idToken) async { + if (state is AuthLoading) return; + state = const AuthLoading(); + try { + final tokens = await _apiClient.loginWithTelegram(idToken); + await _tokenStorage.save(tokens: tokens, expiresAt: DateTime.now().add(Duration(seconds: tokens.expiresIn))); + final user = tokens.user; + if (user != null) { + state = AuthLoggedIn(user); + } else { + state = const AuthError(const AuthFailure.sessionExpired()); + } + } on AuthException catch (e) { + state = AuthError(e.failure); + } catch (e) { + loggy.warning('unexpected login error', e); + state = const AuthError(AuthFailure.unexpected()); + } + } + + /// Clears tokens and transitions to the logged-out state. + Future logout() async { + await _tokenStorage.clear(); + state = const AuthLoggedOut(); + } + + /// Immediate logout triggered by a failed token refresh inside the + /// interceptor (does not perform async network calls). + Future forceLogout() async { + await _tokenStorage.clear(); + state = const AuthLoggedOut(); + } + + /// Whether the auth feature is usable at all given the build-time config. + bool get isConfigured => AppConfig.isConfigured; +} \ No newline at end of file diff --git a/lib/features/auth/widgets/login_page.dart b/lib/features/auth/widgets/login_page.dart new file mode 100644 index 0000000000..e43e04ff29 --- /dev/null +++ b/lib/features/auth/widgets/login_page.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/model/app_config.dart'; +import 'package:hiddify/features/auth/notifier/auth_notifier.dart'; +import 'package:hiddify/features/auth/widgets/telegram_login_webview.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +/// First screen shown to unauthenticated users. +/// +/// A deliberately minimal UI: a single prominent CTA ("Sign in with Telegram"). +/// The actual Telegram OIDC handshake runs inside a [TelegramLoginWebView]. +class LoginPage extends ConsumerStatefulWidget { + const LoginPage({super.key}); + + @override + ConsumerState createState() => _LoginPageState(); +} + +class _LoginPageState extends ConsumerState { + String? _webviewError; + + Future _startTelegramLogin() async { + if (!AppConfig.isConfigured) { + setState(() => _webviewError = 'not_configured'); + return; + } + setState(() => _webviewError = null); + + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => _TelegramLoginDialog( + onError: (message) => Navigator.of(context).pop(message), + ), + ); + + if (!mounted || result == null) return; + + final authError = _translateWebviewError(result); + if (authError != null) { + setState(() => _webviewError = authError); + return; + } + + await ref.read(authNotifierProvider.notifier).loginWithTelegram(result); + } + + String? _translateWebviewError(String key) { + final t = ref.read(translationsProvider).requireValue; + return switch (key) { + 'not_configured' => t.errors.auth.notConfigured, + 'telegram_login_error' => t.errors.auth.telegramLogin, + 'invalid_response' => t.errors.auth.invalidToken, + _ => t.errors.auth.unexpected, + }; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final t = ref.watch(translationsProvider).requireValue; + final authState = ref.watch(authNotifierProvider); + final isLoading = authState is AuthLoading; + final errorMessage = switch (authState) { + AuthError(:final failure) => failure.present(t).type, + _ => _webviewError, + }; + + return Scaffold( + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon(Icons.verified_user_rounded, size: 72, color: theme.colorScheme.primary), + const Gap(16), + Text( + t.pages.auth.title, + textAlign: TextAlign.center, + style: theme.textTheme.headlineSmall, + ), + const Gap(8), + Text( + t.pages.auth.subtitle, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + const Gap(32), + FilledButton.icon( + onPressed: isLoading ? null : _startTelegramLogin, + icon: isLoading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.telegram), + label: Text(isLoading ? t.pages.auth.signingIn : t.pages.auth.signIn), + ), + if (errorMessage != null) ...[ + const Gap(16), + Text( + errorMessage, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.error), + ), + ], + ], + ), + ), + ), + ), + ); + } +} + +/// Hosts the [TelegramLoginWebView] in a dialog that pops with the extracted +/// `id_token` on success, or a (non-technical) error key on failure. +class _TelegramLoginDialog extends StatelessWidget { + const _TelegramLoginDialog({required this.onError}); + + final void Function(String message) onError; + + @override + Widget build(BuildContext context) { + return Dialog( + child: SizedBox( + width: 360, + height: 480, + child: TelegramLoginWebView( + onResult: (idToken) => Navigator.of(context).pop(idToken), + onError: onError, + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/auth/widgets/telegram_login_webview.dart b/lib/features/auth/widgets/telegram_login_webview.dart new file mode 100644 index 0000000000..1a32a0ac63 --- /dev/null +++ b/lib/features/auth/widgets/telegram_login_webview.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:hiddify/core/model/app_config.dart'; +import 'package:hiddify/features/auth/widgets/telegram_login_webview_html.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +/// A thin WebView that hosts a minimal in-app HTML page which drives the +/// Telegram Login JS SDK and streams the resulting `{ id_token, user, error }` +/// back to Dart via a `JavascriptChannel`. +/// +/// Deliberately does NOT load the Bedolaga React cabinet; only a local data: +/// URL is used so no third-party UI is shown. +class TelegramLoginWebView extends StatefulWidget { + const TelegramLoginWebView({super.key, required this.onResult, required this.onError}); + + /// Called with the extracted `id_token` when Telegram login succeeds. + final void Function(String idToken) onResult; + + /// Called with a user-friendly message when the Telegram login fails + /// (e.g. the user closed the popup or an error was returned). + final void Function(String message) onError; + + @override + State createState() => _TelegramLoginWebViewState(); +} + +class _TelegramLoginWebViewState extends State { + late final WebViewController _controller; + bool _resultReported = false; + + @override + void initState() { + super.initState(); + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..addJavaScriptChannel( + 'TelegramAuth', + onMessageReceived: _onMessage, + ) + ..loadRequest(Uri.dataFromString( + telegramLoginHtml(clientId: AppConfig.telegramOidcClientId), + mimeType: 'text/html', + encoding: Encoding.getByName('utf-8'), + )); + } + + void _onMessage(JavaScriptMessage message) { + if (_resultReported) return; + final data = _tryDecode(message.message); + if (data == null) { + _reportError('invalid_response'); + return; + } + final idToken = data['id_token']; + final error = data['error']; + if (idToken is String && idToken.isNotEmpty) { + _resultReported = true; + widget.onResult(idToken); + } else if (error != null) { + _reportError('telegram_login_error'); + } else { + _reportError('invalid_response'); + } + } + + void _reportError(String key) { + if (_resultReported) return; + _resultReported = true; + widget.onError(key); + } + + Map? _tryDecode(String raw) { + try { + final decoded = jsonDecode(raw); + return decoded is Map ? decoded : null; + } catch (_) { + return null; + } + } + + @override + Widget build(BuildContext context) { + return WebViewWidget(controller: _controller); + } +} \ No newline at end of file diff --git a/lib/features/auth/widgets/telegram_login_webview_html.dart b/lib/features/auth/widgets/telegram_login_webview_html.dart new file mode 100644 index 0000000000..a7f520bcd9 --- /dev/null +++ b/lib/features/auth/widgets/telegram_login_webview_html.dart @@ -0,0 +1,59 @@ +/// Builds the minimal in-app HTML page that drives the Telegram Login JS SDK. +/// +/// It connects to `https://oauth.telegram.org/js/telegram-login.js` and calls +/// `Telegram.Login.init(...)` with the build-time client id, then forwards the +/// callback payload to Dart through the `TelegramAuth` JavascriptChannel. +String telegramLoginHtml({required String clientId}) { + // The client id is numeric and must appear as a raw number literal in the JS. + final clientIdLiteral = clientId.trim(); + return ''' + + + + + + Telegram Login + + + +
+ + + + +'''; +} \ No newline at end of file diff --git a/lib/features/home/widget/connection_button.dart b/lib/features/home/widget/connection_button.dart index 44e23265d9..989330e71f 100644 --- a/lib/features/home/widget/connection_button.dart +++ b/lib/features/home/widget/connection_button.dart @@ -3,20 +3,16 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:gap/gap.dart'; import 'package:hiddify/core/localization/translations.dart'; -import 'package:hiddify/core/model/failures.dart'; import 'package:hiddify/core/router/bottom_sheets/bottom_sheets_notifier.dart'; import 'package:hiddify/core/router/dialog/dialog_notifier.dart'; -import 'package:hiddify/core/router/dialog/widgets/custom_alert_dialog.dart'; import 'package:hiddify/core/theme/theme_extensions.dart'; import 'package:hiddify/core/widget/animated_text.dart'; import 'package:hiddify/features/connection/model/connection_status.dart'; import 'package:hiddify/features/connection/notifier/connection_notifier.dart'; import 'package:hiddify/features/profile/notifier/active_profile_notifier.dart'; import 'package:hiddify/features/proxy/active/active_proxy_notifier.dart'; -import 'package:hiddify/features/settings/data/config_option_repository.dart'; import 'package:hiddify/features/settings/notifier/config_option/config_option_notifier.dart'; import 'package:hiddify/gen/assets.gen.dart'; -import 'package:hiddify/singbox/model/singbox_config_enum.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; // TODO: rewrite diff --git a/lib/features/home/widget/new_con_button.dart b/lib/features/home/widget/new_con_button.dart index 77b257e198..52707f2f6b 100644 --- a/lib/features/home/widget/new_con_button.dart +++ b/lib/features/home/widget/new_con_button.dart @@ -11,13 +11,13 @@ class CircleDesignWidget extends StatelessWidget { final String label; const CircleDesignWidget({ - Key? key, + super.key, required this.animationValue, required this.color, required this.onTap, required this.enabled, required this.label, - }) : super(key: key); + }); // GestureDetector( // onTap: onTap, // child: CustomPaint( @@ -100,7 +100,7 @@ class CirclePainter extends CustomPainter { // Outer circle (pulsing animation for connecting state) final Paint outerCirclePaint = Paint() - ..color = baseColor.withOpacity(0.15) + ..color = baseColor.withValues(alpha: 0.15) ..style = PaintingStyle.fill; final double outerRadius = 84 * animationValue; @@ -108,7 +108,7 @@ class CirclePainter extends CustomPainter { // Middle circle final Paint middleCirclePaint = Paint() - ..color = baseColor.withOpacity(.3) + ..color = baseColor.withValues(alpha: .3) ..style = PaintingStyle.fill; final double middleRadius = 60 * animationValue + (1 - animationValue) / 3; canvas.drawCircle(Offset(cx, cy), middleRadius, middleCirclePaint); @@ -120,7 +120,7 @@ class CirclePainter extends CustomPainter { end: Alignment.bottomCenter, colors: innerCircleColor, ).createShader(Rect.fromCircle(center: Offset(cx, cy), radius: 36)); - final double innerRadius = 36; + const double innerRadius = 36; canvas.drawCircle(Offset(cx, cy), innerRadius, innerCirclePaint); // Draw path and vertical line (same as original) diff --git a/lib/features/home/widget/new_connection_button.dart b/lib/features/home/widget/new_connection_button.dart index c788ea8cac..5959083676 100644 --- a/lib/features/home/widget/new_connection_button.dart +++ b/lib/features/home/widget/new_connection_button.dart @@ -50,16 +50,12 @@ class _CircleDesignWidgetState extends State with SingleTick switch (_currentState) { case ConnectionStateStatus.disconnected: changeState(ConnectionStateStatus.connecting); - break; case ConnectionStateStatus.connecting: changeState(ConnectionStateStatus.connected); - break; case ConnectionStateStatus.connected: changeState(ConnectionStateStatus.error); - break; case ConnectionStateStatus.error: changeState(ConnectionStateStatus.disconnected); - break; } }, child: CustomPaint( @@ -100,7 +96,7 @@ class CirclePainter extends CustomPainter { // Outer circle (pulsing animation for connecting state) final Paint outerCirclePaint = Paint() - ..color = baseColor.withOpacity(0.15) + ..color = baseColor.withValues(alpha: 0.15) ..style = PaintingStyle.fill; // double outerRadius = (size.width / 2) * (currentState == ConnectionStateStatus.connecting ? animationValue : 1); final double outerRadius = @@ -113,7 +109,7 @@ class CirclePainter extends CustomPainter { // Middle circle final Paint middleCirclePaint = Paint() - ..color = baseColor.withOpacity(.3) + ..color = baseColor.withValues(alpha: .3) ..style = PaintingStyle.fill; final double middleRadius = 60 * @@ -129,7 +125,7 @@ class CirclePainter extends CustomPainter { end: Alignment.bottomCenter, colors: innerCircleColor, ).createShader(Rect.fromCircle(center: Offset(cx, cy), radius: 36)); - final double innerRadius = 36; //* (currentState == ConnectionStateStatus.connecting ? animationValue : 1); + const double innerRadius = 36; //* (currentState == ConnectionStateStatus.connecting ? animationValue : 1); canvas.drawCircle(Offset(cx, cy), innerRadius, innerCirclePaint); final Paint pathPaint = Paint() ..color = Colors.white diff --git a/lib/features/per_app_proxy/overview/per_app_proxy_notifier.dart b/lib/features/per_app_proxy/overview/per_app_proxy_notifier.dart index 70860170f2..de21e5ce08 100644 --- a/lib/features/per_app_proxy/overview/per_app_proxy_notifier.dart +++ b/lib/features/per_app_proxy/overview/per_app_proxy_notifier.dart @@ -54,8 +54,8 @@ class PerAppProxy extends _$PerAppProxy with AppLogger { final rs = await ref.watch(autoSelectionRepoProvider).getByAppProxyMode(mode: _mode); switch (rs.$2) { case AutoSelectionResult.success: - final autoList = rs.$1!; - await ref.read(appProxyDataSourceProvider).applyAutoSelection(autoList: autoList, mode: _mode!); + final autoList = rs.$1; + await ref.read(appProxyDataSourceProvider).applyAutoSelection(autoList: autoList!, mode: _mode!); await ref.read(Preferences.autoAppsSelectionRegion.notifier).update(region); await ref.read(Preferences.autoAppsSelectionLastUpdate.notifier).update(DateTime.now()); return true; @@ -175,17 +175,17 @@ class PerAppProxy extends _$PerAppProxy with AppLogger { Future shareOnGithub() async { final t = ref.watch(translationsProvider).requireValue; final region = ref.watch(ConfigOptions.region); - final mode = ref.watch(Preferences.perAppProxyMode).toAppProxy()!; + final mode = ref.watch(Preferences.perAppProxyMode).toAppProxy(); assert(region != Region.other); final rs = await ref.read(autoSelectionRepoProvider).getByAppProxyMode(mode: mode, region: region); if (rs.$2 != AutoSelectionResult.success) return false; - final autoList = rs.$1!; + final autoList = rs.$1; final userSelected = - (await ref.read(appProxyDataSourceProvider).getPkgsByFlag(mode: mode, flag: PkgFlag.userSelection)) - ..removeWhere((pkg) => autoList.contains(pkg)); + (await ref.read(appProxyDataSourceProvider).getPkgsByFlag(mode: mode!, flag: PkgFlag.userSelection)) + ..removeWhere((pkg) => autoList!.contains(pkg)); final forceDeselected = - (await ref.read(appProxyDataSourceProvider).getPkgsByFlag(mode: mode, flag: PkgFlag.forceDeselection)) - ..removeWhere((pkg) => !autoList.contains(pkg)); + (await ref.read(appProxyDataSourceProvider).getPkgsByFlag(mode: mode!, flag: PkgFlag.forceDeselection)) + ..removeWhere((pkg) => !autoList!.contains(pkg)); if (userSelected.isNotEmpty || forceDeselected.isNotEmpty) { final agree = await ref @@ -196,7 +196,7 @@ class PerAppProxy extends _$PerAppProxy with AppLogger { positiveBtnTxt: t.common.kContinue, ); if (agree != true) return false; - final title = '${region.name} | ${mode.present(t).title}'; + final title = '${region.name} | ${mode!.present(t).title}'; var body = const JsonEncoder.withIndent( ' ', ).convert({'addedPkgs': userSelected.toList(), 'removedPkgs': forceDeselected.toList()}); diff --git a/lib/features/per_app_proxy/overview/per_app_proxy_page.dart b/lib/features/per_app_proxy/overview/per_app_proxy_page.dart index 49ffcf7b90..48544b12c6 100644 --- a/lib/features/per_app_proxy/overview/per_app_proxy_page.dart +++ b/lib/features/per_app_proxy/overview/per_app_proxy_page.dart @@ -66,8 +66,9 @@ class PerAppProxyPage extends HookConsumerWidget with PresLogger { if (!(selectedApps.hasValue && selectedApps is AsyncData && asyncFilteredApps.hasData && - asyncFilteredApps.connectionState == ConnectionState.done)) + asyncFilteredApps.connectionState == ConnectionState.done)) { return const AsyncValue.loading(); + } final appsList = asyncFilteredApps.requireData.toList(); if (searchQuery.value.isBlank) { appsList.sort((a, b) { @@ -244,8 +245,9 @@ class PerAppProxyPage extends HookConsumerWidget with PresLogger { tooltip: (mode?.toPerAppProxy() ?? PerAppProxyMode.off).present(t).message, initialValue: mode?.toPerAppProxy() ?? PerAppProxyMode.off, onSelected: (e) async { - if (ref.read(Preferences.autoAppsSelectionRegion) != null) + if (ref.read(Preferences.autoAppsSelectionRegion) != null) { await ref.read(PerAppProxyProvider(mode).notifier).clearAutoSelected(); + } if (e == PerAppProxyMode.off && context.mounted) context.pop(); await ref.read(Preferences.perAppProxyMode.notifier).update(e); }, diff --git a/lib/features/profile/add/model/free_profiles_model.dart b/lib/features/profile/add/model/free_profiles_model.dart index 8f76899572..44495c9558 100644 --- a/lib/features/profile/add/model/free_profiles_model.dart +++ b/lib/features/profile/add/model/free_profiles_model.dart @@ -1,5 +1,4 @@ // This file is "main.dart" -import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'free_profiles_model.freezed.dart'; diff --git a/lib/features/profile/data/profile_data_source.dart b/lib/features/profile/data/profile_data_source.dart index 357a71afcd..9a4ad2b0e8 100644 --- a/lib/features/profile/data/profile_data_source.dart +++ b/lib/features/profile/data/profile_data_source.dart @@ -58,7 +58,7 @@ class ProfileDao extends DatabaseAccessor with _$ProfileDaoMixin, InfraLogge @override Stream watchProfilesCount() { final count = profileEntries.id.count(); - return (profileEntries.selectOnly()..addColumns([count])).map((exp) => exp.read(count)!).watchSingle().distinct(); + return (profileEntries.selectOnly()..addColumns([count])).map((exp) => exp.read(count) ?? 0).watchSingle().distinct(); } @override diff --git a/lib/features/profile/details/json_editor.dart b/lib/features/profile/details/json_editor.dart index 7248e08b44..94f4c78e3e 100644 --- a/lib/features/profile/details/json_editor.dart +++ b/lib/features/profile/details/json_editor.dart @@ -1,11 +1,8 @@ -library json_editor_flutter; -import 'dart:convert'; import 'dart:async'; +import 'dart:convert'; import 'dart:math'; -import 'dart:ui'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; @@ -492,7 +489,7 @@ class _JsonEditorState extends State { Map getExpandedParents() { final map = {}; - for (var key in widget.expandedObjects) { + for (final key in widget.expandedObjects) { if (key is List) { final newExpandList = ["config", ...key]; for (int i = newExpandList.length - 1; i > 0; i--) { @@ -532,7 +529,7 @@ class _JsonEditorState extends State { }); } - void copyData() async { + Future copyData() async { await Clipboard.setData(ClipboardData(text: const JsonEncoder.withIndent(' ').convert(_data))); } @@ -551,7 +548,7 @@ class _JsonEditorState extends State { void findMatchingKeys(data, String text, List nestedParents) { if (data is Map) { final keys = data.keys.toList(); - for (var key in keys) { + for (final key in keys) { final keyName = key.toString(); if (keyName.toLowerCase().contains(text) || (data[key] is String && data[key].toString().toLowerCase().contains(text))) { @@ -607,7 +604,7 @@ class _JsonEditorState extends State { void calculateOffset(data, List parents, List toFind) { if (keyFound) return; if (data is Map) { - for (var entry in data.entries) { + for (final entry in data.entries) { if (keyFound) return; offset++; final newList = [...parents, entry.key]; @@ -672,7 +669,7 @@ class _JsonEditorState extends State { void expandAllObjects(data, List expandedList) { if (data is Map) { - for (var entry in data.entries) { + for (final entry in data.entries) { if (entry.value is Map || entry.value is List) { final newList = [...expandedList, entry.key]; _expandedObjects[newList.toString()] = true; @@ -847,7 +844,6 @@ class _JsonEditorState extends State { controller: _controller, onChanged: parseData, maxLines: null, - minLines: null, expands: true, textAlignVertical: TextAlignVertical.top, decoration: const InputDecoration( @@ -895,7 +891,7 @@ class _Holder extends StatefulWidget { // ? '.${parentObject['type']}' // : ''; - return '$basePath'; + return basePath; } @override @@ -927,9 +923,9 @@ class _HolderState extends State<_Holder> { widget.setState(() {}); } else if (selectedItem == "map") { if (widget.data is Map) { - widget.data[_newKey] = Map(); + widget.data[_newKey] = {}; } else { - widget.data.add(Map()); + widget.data.add({}); } setState(() {}); @@ -993,7 +989,7 @@ class _HolderState extends State<_Holder> { var res = "{"; if (data is Map) { if (widget.expandedObjects[widget.allParents.toString()] ?? false) return ""; - final content = data as Map; + final content = data; //res += "${data.length}"; if (content["type"] != null) { res += "${content["type"]}"; @@ -1005,10 +1001,10 @@ class _HolderState extends State<_Holder> { res += " [${d.substring(0, min(20, d.length))}...]"; } } else if (data is List) { - final content = data as List; + final content = data; res += "${content.length}"; } - return res + "}"; + return "$res}"; } @override @@ -1017,7 +1013,7 @@ class _HolderState extends State<_Holder> { final mapWidget = []; final widgetData = widget.data as Map; final List keys = widgetData.keys.toList(); - for (var key in keys) { + for (final key in keys) { mapWidget.add( _Holder( key: Key(key), @@ -1310,7 +1306,6 @@ class _ReplaceTextWithFieldState extends State<_ReplaceTextWithField> { hint: Text('Select ${widget.keyPath.replaceAll("config.outbounds", "")}'), value: _text, icon: const Icon(Icons.arrow_downward), - iconSize: 24, elevation: 16, underline: Container(height: 2), onChanged: (String? newValue) { @@ -1451,7 +1446,7 @@ class _Options extends StatelessWidget { PopupMenuItem<_OptionItems>( height: _popupMenuHeight, padding: const EdgeInsets.only(left: _popupMenuItemPadding), - value: key + "___" + key2, + value: "${key}___$key2", child: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/features/profile/details/profile_details_page.dart b/lib/features/profile/details/profile_details_page.dart index 7e83c39dfb..a248dfe436 100644 --- a/lib/features/profile/details/profile_details_page.dart +++ b/lib/features/profile/details/profile_details_page.dart @@ -269,7 +269,6 @@ class ProfileDetailsPage extends HookConsumerWidget with PresLogger { ref.read(provider.notifier).setContent(value); }, maxLines: null, - minLines: null, expands: true, textAlignVertical: TextAlignVertical.top, decoration: const InputDecoration( diff --git a/lib/features/profile/widget/profile_tile_main.dart b/lib/features/profile/widget/profile_tile_main.dart index df4bba37e4..77c664e1c4 100644 --- a/lib/features/profile/widget/profile_tile_main.dart +++ b/lib/features/profile/widget/profile_tile_main.dart @@ -76,7 +76,7 @@ class ProfileTileMain extends HookConsumerWidget { Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer.withOpacity(0.1), + color: theme.colorScheme.primaryContainer.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Icon(FluentIcons.arrow_sync_24_filled, color: theme.colorScheme.primary, size: 20), @@ -93,7 +93,7 @@ class ProfileTileMain extends HookConsumerWidget { ), ), if (subInfo != null) - Container( + SizedBox( width: 350, child: Column( mainAxisSize: MainAxisSize.min, @@ -125,7 +125,7 @@ class ProfileTileMain extends HookConsumerWidget { ], ), ), - if ((subInfo.webPageUrl != null || subInfo.supportUrl != null)) + if (subInfo.webPageUrl != null || subInfo.supportUrl != null) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( @@ -255,7 +255,7 @@ class _UsageRow extends StatelessWidget { borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: progress, - backgroundColor: Theme.of(context).colorScheme.surfaceVariant, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, valueColor: AlwaysStoppedAnimation(color), minHeight: 4, ), @@ -298,7 +298,7 @@ class _InfoItem extends StatelessWidget { Text( label, style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant.withOpacity(0.7), + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7), ), ), Text(value, style: theme.textTheme.bodyMedium, maxLines: 1, overflow: TextOverflow.ellipsis), diff --git a/lib/features/proxy/active/active_proxy_card.dart b/lib/features/proxy/active/active_proxy_card.dart index 8be53b8d40..9fcee86cb4 100644 --- a/lib/features/proxy/active/active_proxy_card.dart +++ b/lib/features/proxy/active/active_proxy_card.dart @@ -44,10 +44,10 @@ class ActiveProxyFooter extends ConsumerWidget with InfraLogger { margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), padding: const EdgeInsets.symmetric(vertical: 12), decoration: BoxDecoration( - color: theme.colorScheme.background.withOpacity(1), + color: theme.colorScheme.surface.withValues(alpha: 1), borderRadius: BorderRadius.circular(20), boxShadow: [ - BoxShadow(color: theme.colorScheme.secondary.withOpacity(.21), blurRadius: 10, offset: const Offset(0, 4)), + BoxShadow(color: theme.colorScheme.secondary.withValues(alpha: .21), blurRadius: 10, offset: const Offset(0, 4)), ], ), child: InkWell( diff --git a/lib/features/proxy/active/active_proxy_delay_indicator.dart b/lib/features/proxy/active/active_proxy_delay_indicator.dart index 901086fc73..3368dc091f 100644 --- a/lib/features/proxy/active/active_proxy_delay_indicator.dart +++ b/lib/features/proxy/active/active_proxy_delay_indicator.dart @@ -20,7 +20,8 @@ class ActiveProxyDelayIndicator extends HookConsumerWidget with InfraLogger { return const SizedBox(); // Avoid building widget if data is not available } - final proxy = activeProxy.value!; + final proxy = activeProxy.value; + if (proxy == null) return const SizedBox(); final delay = proxy.urlTestDelay; final timeout = delay > 65000; diff --git a/lib/features/proxy/model/proxy_entity.dart b/lib/features/proxy/model/proxy_entity.dart index b9830f16c0..fdd240dcf5 100644 --- a/lib/features/proxy/model/proxy_entity.dart +++ b/lib/features/proxy/model/proxy_entity.dart @@ -1,5 +1,4 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hiddify/singbox/model/singbox_proxy_type.dart'; part 'proxy_entity.freezed.dart'; diff --git a/lib/features/proxy/overview/proxies_overview_notifier.dart b/lib/features/proxy/overview/proxies_overview_notifier.dart index 1d84b1d26b..8d34ae6063 100644 --- a/lib/features/proxy/overview/proxies_overview_notifier.dart +++ b/lib/features/proxy/overview/proxies_overview_notifier.dart @@ -203,7 +203,8 @@ class ProxiesOverviewNotifier extends _$ProxiesOverviewNotifier with AppLogger { Future changeProxy(String groupTag, String outboundTag) async { loggy.debug("changing proxy, group: [$groupTag] - outbound: [$outboundTag]"); if (!state.hasValue) return; - final outbounds = state.value!; + final outbounds = state.value; + if (outbounds == null) return; await ref.read(hapticServiceProvider.notifier).lightImpact(); await ref.read(proxyRepositoryProvider).selectProxy(groupTag, outboundTag).getOrElse((err) { loggy.warning("error selecting outbound", err); diff --git a/lib/features/route_rules/widget/setting_detail_chips.dart b/lib/features/route_rules/widget/setting_detail_chips.dart index 0f0c131e1a..1226348564 100644 --- a/lib/features/route_rules/widget/setting_detail_chips.dart +++ b/lib/features/route_rules/widget/setting_detail_chips.dart @@ -152,7 +152,7 @@ class SettingDetailChip extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); return Container( - decoration: BoxDecoration(color: theme.colorScheme.surfaceVariant, borderRadius: BorderRadius.circular(8)), + decoration: BoxDecoration(color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: isPackageName ? AndroidAppInfo(packageName: '$value') : Padding(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: valueByType(value, theme)), diff --git a/lib/features/settings/overview/sections/general_page.dart b/lib/features/settings/overview/sections/general_page.dart index d3f4a32486..9714edc105 100644 --- a/lib/features/settings/overview/sections/general_page.dart +++ b/lib/features/settings/overview/sections/general_page.dart @@ -76,10 +76,11 @@ class GeneralPage extends HookConsumerWidget { secondary: const Icon(Icons.bug_report_rounded), value: ref.watch(debugModeNotifierProvider), onChanged: (value) async { - if (value) + if (value) { await ref .read(dialogNotifierProvider.notifier) .showOk(t.pages.settings.general.debugMode, t.pages.settings.general.debugModeMsg); + } await ref.read(debugModeNotifierProvider.notifier).update(value); }, ), diff --git a/lib/features/settings/overview/sections/tls_tricks_page.dart b/lib/features/settings/overview/sections/tls_tricks_page.dart index da5f5c8552..34eacbb6cc 100644 --- a/lib/features/settings/overview/sections/tls_tricks_page.dart +++ b/lib/features/settings/overview/sections/tls_tricks_page.dart @@ -35,7 +35,7 @@ class TlsTricksPage extends HookConsumerWidget { ChoicePreferenceWidget( selected: ref.watch(ConfigOptions.fragmentPackets), preferences: ref.watch(ConfigOptions.fragmentPackets.notifier), - choices: ["tlshello", "1-1", "1-2", "1-3", "1-4", "1-5"], + choices: const ["tlshello", "1-1", "1-2", "1-3", "1-4", "1-5"], title: t.pages.settings.tlsTricks.packets, icon: Icons.layers_rounded, presentChoice: (value) => _presentFragmentPackets(t, value), diff --git a/lib/features/stats/widget/connection_stats_card.dart b/lib/features/stats/widget/connection_stats_card.dart index 9acf9c4afd..f86f4dbef6 100644 --- a/lib/features/stats/widget/connection_stats_card.dart +++ b/lib/features/stats/widget/connection_stats_card.dart @@ -32,7 +32,7 @@ class ConnectionStatsCard extends HookConsumerWidget { AsyncData(value: final proxy) when proxy.ipinfo.ip.isNotEmpty => ( label: Row( children: [ - IPCountryFlag(countryCode: proxy.ipinfo.countryCode, size: 16), + IPCountryFlag(countryCode: proxy.ipinfo.countryCode), // const Gap(4), // OrganisationFlag(organization: proxy.ipinfo.org, size: 16), ], diff --git a/lib/hiddifycore/core_interface/core_interface_desktop.dart b/lib/hiddifycore/core_interface/core_interface_desktop.dart index d582670625..c989bbbfc0 100644 --- a/lib/hiddifycore/core_interface/core_interface_desktop.dart +++ b/lib/hiddifycore/core_interface/core_interface_desktop.dart @@ -7,7 +7,6 @@ import 'package:grpc/grpc.dart'; import 'package:hiddify/core/model/directories.dart'; import 'package:hiddify/gen/hiddify_core_generated_bindings.dart'; import 'package:hiddify/hiddifycore/core_interface/core_interface.dart'; -import 'package:hiddify/hiddifycore/core_interface/mtls_channel_cred.dart'; import 'package:hiddify/hiddifycore/generated/v2/hcore/hcore.pb.dart'; import 'package:hiddify/hiddifycore/generated/v2/hcore/hcore_service.pbgrpc.dart'; import 'package:hiddify/hiddifycore/generated/v2/hello/hello.pb.dart'; diff --git a/lib/hiddifycore/core_interface/core_interface_mobile.dart b/lib/hiddifycore/core_interface/core_interface_mobile.dart index 91e02da655..c18cb87f53 100644 --- a/lib/hiddifycore/core_interface/core_interface_mobile.dart +++ b/lib/hiddifycore/core_interface/core_interface_mobile.dart @@ -138,7 +138,7 @@ class CoreInterfaceMobile extends CoreInterface with InfraLogger { } loggy.info("Waiting for starting core finished"); - if (!await waitUntilPort(portBack, true, null, maxTry: 10)) { + if (!await waitUntilPort(portBack, true, null)) { await stopMethodChannel(); return const CoreStatus.stopped(alert: CoreAlert.startService, message: "starting background core..."); } @@ -148,7 +148,7 @@ class CoreInterfaceMobile extends CoreInterface with InfraLogger { @override Future stop() async { await stopMethodChannel(); - if (!await waitUntilPort(portBack, false, null, maxTry: 10)) { + if (!await waitUntilPort(portBack, false, null)) { return false; } diff --git a/lib/hiddifycore/core_interface/mtls_channel_cred.dart b/lib/hiddifycore/core_interface/mtls_channel_cred.dart index 837ea39d13..21bb551ce7 100644 --- a/lib/hiddifycore/core_interface/mtls_channel_cred.dart +++ b/lib/hiddifycore/core_interface/mtls_channel_cred.dart @@ -5,7 +5,7 @@ import 'package:basic_utils/basic_utils.dart'; import 'package:grpc/grpc.dart'; class MTLSChannelCredentials extends ChannelCredentials { - final SecurityContext ctx = SecurityContext(withTrustedRoots: false); + final SecurityContext ctx = SecurityContext(); MTLSChannelCredentials({ required Uint8List serverPublicKey, diff --git a/lib/hiddifycore/generated/extension/extension_service.pbgrpc.dart b/lib/hiddifycore/generated/extension/extension_service.pbgrpc.dart index 983782c362..cbd4a60a70 100644 --- a/lib/hiddifycore/generated/extension/extension_service.pbgrpc.dart +++ b/lib/hiddifycore/generated/extension/extension_service.pbgrpc.dart @@ -14,11 +14,10 @@ import 'dart:async' as $async; import 'dart:core' as $core; import 'package:grpc/service_api.dart' as $grpc; +import 'package:hiddify/hiddifycore/generated/extension/extension.pb.dart' as $1; +import 'package:hiddify/hiddifycore/generated/v2/hcommon/common.pb.dart' as $0; import 'package:protobuf/protobuf.dart' as $pb; -import '../v2/hcommon/common.pb.dart' as $0; -import 'extension.pb.dart' as $1; - export 'extension_service.pb.dart'; @$pb.GrpcServiceName('extension.ExtensionHostService') diff --git a/lib/hiddifycore/generated/v2/ezytel/ezytel_service.pbgrpc.dart b/lib/hiddifycore/generated/v2/ezytel/ezytel_service.pbgrpc.dart index 59d7b06001..0fc4036cbe 100644 --- a/lib/hiddifycore/generated/v2/ezytel/ezytel_service.pbgrpc.dart +++ b/lib/hiddifycore/generated/v2/ezytel/ezytel_service.pbgrpc.dart @@ -14,10 +14,9 @@ import 'dart:async' as $async; import 'dart:core' as $core; import 'package:grpc/service_api.dart' as $grpc; +import 'package:hiddify/hiddifycore/generated/v2/ezytel/ezytel.pb.dart' as $0; import 'package:protobuf/protobuf.dart' as $pb; -import 'ezytel.pb.dart' as $0; - export 'ezytel_service.pb.dart'; /// Ezytel exposes the Telegram public-channel viewer originally shipped as diff --git a/lib/hiddifycore/generated/v2/hcore/hcore_service.pbgrpc.dart b/lib/hiddifycore/generated/v2/hcore/hcore_service.pbgrpc.dart index 67e9ac1c67..3c72b31cac 100644 --- a/lib/hiddifycore/generated/v2/hcore/hcore_service.pbgrpc.dart +++ b/lib/hiddifycore/generated/v2/hcore/hcore_service.pbgrpc.dart @@ -14,11 +14,10 @@ import 'dart:async' as $async; import 'dart:core' as $core; import 'package:grpc/service_api.dart' as $grpc; +import 'package:hiddify/hiddifycore/generated/v2/hcommon/common.pb.dart' as $1; +import 'package:hiddify/hiddifycore/generated/v2/hcore/hcore.pb.dart' as $0; import 'package:protobuf/protobuf.dart' as $pb; -import '../hcommon/common.pb.dart' as $1; -import 'hcore.pb.dart' as $0; - export 'hcore_service.pb.dart'; @$pb.GrpcServiceName('hcore.Core') diff --git a/lib/hiddifycore/generated/v2/hcore/tunnelservice/tunnel_service.pbgrpc.dart b/lib/hiddifycore/generated/v2/hcore/tunnelservice/tunnel_service.pbgrpc.dart index 92af08f18e..0964a3418a 100644 --- a/lib/hiddifycore/generated/v2/hcore/tunnelservice/tunnel_service.pbgrpc.dart +++ b/lib/hiddifycore/generated/v2/hcore/tunnelservice/tunnel_service.pbgrpc.dart @@ -14,11 +14,10 @@ import 'dart:async' as $async; import 'dart:core' as $core; import 'package:grpc/service_api.dart' as $grpc; +import 'package:hiddify/hiddifycore/generated/v2/hcommon/common.pb.dart' as $1; +import 'package:hiddify/hiddifycore/generated/v2/hcore/tunnelservice/tunnel.pb.dart' as $0; import 'package:protobuf/protobuf.dart' as $pb; -import '../../hcommon/common.pb.dart' as $1; -import 'tunnel.pb.dart' as $0; - export 'tunnel_service.pb.dart'; @$pb.GrpcServiceName('tunnelservice.TunnelService') diff --git a/lib/hiddifycore/generated/v2/hello/hello_service.pbgrpc.dart b/lib/hiddifycore/generated/v2/hello/hello_service.pbgrpc.dart index 4de25601db..df63a02a53 100644 --- a/lib/hiddifycore/generated/v2/hello/hello_service.pbgrpc.dart +++ b/lib/hiddifycore/generated/v2/hello/hello_service.pbgrpc.dart @@ -14,10 +14,9 @@ import 'dart:async' as $async; import 'dart:core' as $core; import 'package:grpc/service_api.dart' as $grpc; +import 'package:hiddify/hiddifycore/generated/v2/hello/hello.pb.dart' as $0; import 'package:protobuf/protobuf.dart' as $pb; -import 'hello.pb.dart' as $0; - export 'hello_service.pb.dart'; @$pb.GrpcServiceName('hello.Hello') diff --git a/lib/hiddifycore/generated/v2/profile/profile_service.pbgrpc.dart b/lib/hiddifycore/generated/v2/profile/profile_service.pbgrpc.dart index d26431def1..ab1061bfac 100644 --- a/lib/hiddifycore/generated/v2/profile/profile_service.pbgrpc.dart +++ b/lib/hiddifycore/generated/v2/profile/profile_service.pbgrpc.dart @@ -14,12 +14,11 @@ import 'dart:async' as $async; import 'dart:core' as $core; import 'package:grpc/service_api.dart' as $grpc; +import 'package:hiddify/hiddifycore/generated/v2/hcommon/common.pb.dart' as $2; +import 'package:hiddify/hiddifycore/generated/v2/profile/profile.pb.dart' as $1; +import 'package:hiddify/hiddifycore/generated/v2/profile/profile_service.pb.dart' as $0; import 'package:protobuf/protobuf.dart' as $pb; -import '../hcommon/common.pb.dart' as $2; -import 'profile.pb.dart' as $1; -import 'profile_service.pb.dart' as $0; - export 'profile_service.pb.dart'; /// * diff --git a/lib/hiddifycore/hiddify_core_service.dart b/lib/hiddifycore/hiddify_core_service.dart index fd4b31743c..7634cf6338 100644 --- a/lib/hiddifycore/hiddify_core_service.dart +++ b/lib/hiddifycore/hiddify_core_service.dart @@ -1,32 +1,26 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:math'; import 'package:fpdart/fpdart.dart'; import 'package:grpc/grpc.dart'; import 'package:hiddify/core/directories/directories_provider.dart'; -import 'package:hiddify/core/model/directories.dart'; import 'package:hiddify/core/notification/in_app_notification_controller.dart'; import 'package:hiddify/core/preferences/general_preferences.dart'; import 'package:hiddify/features/connection/model/connection_failure.dart'; +import 'package:hiddify/features/log/model/log_level.dart' as config_log_level; import 'package:hiddify/features/settings/data/config_option_repository.dart'; -import 'package:hiddify/hiddifycore/core_interface/core_interface.dart'; +import 'package:hiddify/hiddifycore/core_interface/core_interface_wrapper_stub.dart' + if (dart.library.io) 'package:hiddify/hiddifycore/core_interface/core_interface_wrapper.dart'; import 'package:hiddify/hiddifycore/generated/v2/hcommon/common.pb.dart'; import 'package:hiddify/hiddifycore/generated/v2/hcore/hcore.pb.dart'; import 'package:hiddify/hiddifycore/generated/v2/hcore/hcore_service.pbgrpc.dart'; import 'package:hiddify/hiddifycore/init_signal.dart'; -import 'package:hiddify/singbox/model/singbox_config_option.dart'; -import 'package:hiddify/features/log/model/log_level.dart' as config_log_level; import 'package:hiddify/singbox/model/core_status.dart'; -import 'package:hiddify/singbox/model/warp_account.dart'; - -import 'package:hiddify/hiddifycore/core_interface/core_interface_wrapper_stub.dart' - if (dart.library.io) 'package:hiddify/hiddifycore/core_interface/core_interface_wrapper.dart'; +import 'package:hiddify/singbox/model/singbox_config_option.dart'; import 'package:hiddify/utils/custom_loggers.dart'; import 'package:hiddify/utils/platform_utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:loggy/loggy.dart' as loggyl; -import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:rxdart/rxdart.dart'; class HiddifyCoreService with InfraLogger { diff --git a/lib/hiddifycore/init_signal.dart b/lib/hiddifycore/init_signal.dart index ae46039ab1..c8d291dd2f 100644 --- a/lib/hiddifycore/init_signal.dart +++ b/lib/hiddifycore/init_signal.dart @@ -1,8 +1,3 @@ -import 'package:hiddify/core/directories/directories_provider.dart'; -import 'package:hiddify/core/notification/in_app_notification_controller.dart'; -import 'package:hiddify/core/preferences/general_preferences.dart'; -import 'package:hiddify/hiddifycore/hiddify_core_service.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'init_signal.g.dart'; diff --git a/pubspec.lock b/pubspec.lock index 3f128e8641..327d8f1204 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -145,14 +145,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.4" - build_modules: - dependency: transitive - description: - name: build_modules - sha256: "8a605a996691e79c5d81d9051e849680917157e27bd655b9cd2c5bb8019a432e" - url: "https://pub.dev" - source: hosted - version: "5.1.5" build_resolvers: dependency: transitive description: @@ -181,10 +173,10 @@ packages: dependency: "direct dev" description: name: build_web_compilers - sha256: "37b1e61ae004124bfbb49079f73ee548d216acc901a58e65b0ffe02baa21cb96" + sha256: c8be4b48f09289d145c7eaa3240f1e7776c529ea1cecddf483218edd3129de3f url: "https://pub.dev" source: hosted - version: "4.4.6" + version: "4.8.0" built_collection: dependency: transitive description: @@ -205,10 +197,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -1051,18 +1043,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" maybe_just_nothing: dependency: transitive description: @@ -1083,10 +1075,10 @@ packages: dependency: "direct main" description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -1443,10 +1435,10 @@ packages: dependency: transitive description: name: scratch_space - sha256: "3417e014d20b12cebc5bfb1c0b1f63806054177158596cc31cc4d9aaca767a60" + sha256: "55f141cf286eca871f739518b689af61cb994d671f3fd8efabd63aba7578c309" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.2" screen_retriever: dependency: "direct main" description: @@ -1784,10 +1776,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.11" text_scroll: dependency: "direct main" description: @@ -2085,5 +2077,5 @@ packages: source: hosted version: "2.2.2" sdks: - dart: ">=3.10.4 <3.12.0-z" + dart: ">=3.10.4 <3.13.0-z" flutter: ">=3.38.5 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index b7d3d84872..1f16f00740 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,6 +36,8 @@ dependencies: shared_preferences: ^2.5.2 # shared_preferences_android: ^2.4.8 dio: ^5.4.1 + flutter_secure_storage: ^9.2.2 + webview_flutter: ^4.10.0 ffi: ^2.1.2 path_provider: ^2.1.1 mobile_scanner: ^7.2.0 diff --git a/test/drift/db/migration_test.dart b/test/drift/db/migration_test.dart index 2b5440ae3f..20f701d2de 100644 --- a/test/drift/db/migration_test.dart +++ b/test/drift/db/migration_test.dart @@ -2,10 +2,10 @@ // ignore_for_file: unused_local_variable, unused_import import 'package:drift/drift.dart'; import 'package:drift_dev/api/migrations_native.dart'; -import 'package:hiddify/core/db/db.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'generated/schema.dart'; +import 'package:hiddify/core/db/db.dart'; +import 'generated/schema.dart'; import 'generated/schema_v1.dart' as v1; import 'generated/schema_v2.dart' as v2; import 'generated/schema_v3.dart' as v3;