Android Feature Optimization and Updates
Build Android Release / Build Android (push) Failing after 7s

This commit is contained in:
2026-05-21 10:31:43 +08:00
parent 31acfd4e13
commit 1d9e0a831f
19 changed files with 2506 additions and 562 deletions
+59 -1
View File
@@ -1,3 +1,4 @@
import '../data/models/login_config_model.dart';
import '../data/models/user_model.dart';
import 'api_service.dart';
import '../core/utils/app_logger.dart';
@@ -24,16 +25,73 @@ class AuthService {
return response as Map<String, bool>;
}
/// 获取登录配置(是否需要验证码、是否允许注册等)
Future<LoginConfigModel> getLoginConfig() async {
final response = await ApiService.instance.get<Map<String, dynamic>>(
'/site/config/login',
noAuth: true,
);
final data = response['data'];
if (data is Map<String, dynamic>) {
return LoginConfigModel.fromJson(data);
}
if (data is Map) {
return LoginConfigModel.fromJson(Map<String, dynamic>.from(data));
}
return LoginConfigModel.fromJson(response);
}
/// 获取图形验证码
Future<Map<String, String>> getCaptcha() async {
final response = await ApiService.instance.get<Map<String, dynamic>>(
'/site/captcha',
noAuth: true,
);
final data = response['data'] is Map
? Map<String, dynamic>.from(response['data'] as Map)
: response;
return {
'image': data['image'] as String? ?? '',
'ticket': data['ticket'] as String? ?? '',
};
}
/// 获取站点基础配置,用于判断验证码类型。
Future<Map<String, dynamic>> getBasicSiteConfig() async {
final response = await ApiService.instance.get<Map<String, dynamic>>(
'/site/config/basic',
noAuth: true,
);
AppLogger.d('AuthService -> 站点基础配置响应: $response');
final data = response['data'];
if (data is Map<String, dynamic>) {
return data;
}
if (data is Map) {
return Map<String, dynamic>.from(data);
}
return response;
}
/// 密码登录
Future<LoginResponseModel> passwordLogin({
required String email,
required String password,
String? captcha,
String? ticket,
}) async {
final data = <String, dynamic>{
'email': email,
'password': password,
...captcha != null ? {'captcha': captcha} : {},
if (captcha != null && captcha.isNotEmpty) 'captcha': captcha,
if (ticket != null && ticket.isNotEmpty) 'ticket': ticket,
};
final response = await ApiService.instance.post<Map<String, dynamic>>(
+395
View File
@@ -0,0 +1,395 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import '../presentation/pages/auth/captcha_challenge_page.dart';
import '../presentation/widgets/toast_helper.dart';
import '../services/auth_service.dart';
import '../services/server_service.dart';
import 'api_service.dart';
/// 验证码服务(单例)
///
/// 管理验证码的加载、状态和 UI 构建,供登录/注册/忘记密码页面共享。
class CaptchaService {
CaptchaService._internal();
static final CaptchaService _instance = CaptchaService._internal();
static CaptchaService get instance => _instance;
final TextEditingController captchaController = TextEditingController();
String? _captchaType;
String? _recaptchaSiteKey;
String? _turnstileSiteKey;
String? _capInstanceUrl;
String? _capSiteKey;
String? _capAssetServer;
String? _captchaImage;
String? _captchaTicket;
String? _captchaToken;
bool _isLoadingCaptcha = false;
bool get isLoadingCaptcha => _isLoadingCaptcha;
String? get captchaImage => _captchaImage;
String? get captchaTicket => _captchaTicket;
String? get captchaToken => _captchaToken;
bool get isWebCaptcha => captchaWebConfig != null;
CaptchaWebConfig? get captchaWebConfig {
final type = _normalizedCaptchaType;
if (type == 'turnstile' &&
_turnstileSiteKey != null &&
_turnstileSiteKey!.isNotEmpty) {
return CaptchaWebConfig.turnstile(
siteKey: _turnstileSiteKey!,
displayName: 'Cloudflare Turnstile',
);
}
if (type == 'recaptcha' &&
_recaptchaSiteKey != null &&
_recaptchaSiteKey!.isNotEmpty) {
return CaptchaWebConfig.recaptchaV2(
siteKey: _recaptchaSiteKey!,
displayName: 'reCAPTCHA V2',
);
}
if (type == 'cap' &&
_capInstanceUrl != null &&
_capInstanceUrl!.isNotEmpty &&
_capSiteKey != null &&
_capSiteKey!.isNotEmpty) {
return CaptchaWebConfig.cap(
instanceUrl: _capInstanceUrl!,
siteKey: _capSiteKey!,
assetServer: _capAssetServer,
displayName: 'Cap',
);
}
return null;
}
String get _normalizedCaptchaType {
final raw = (_captchaType ?? '').trim().toLowerCase();
if (raw == 'recaptcha_v2' ||
raw == 'recaptchav2' ||
raw == 'google' ||
raw == 'google_recaptcha' ||
raw == 'google-recaptcha') {
return 'recaptcha';
}
if (raw == 'cloudflare_turnstile' || raw == 'cloudflare-turnstile') {
return 'turnstile';
}
if (raw == 'image' || raw == 'graphic' || raw == 'captcha') {
return 'normal';
}
return raw;
}
/// 加载验证码配置和图片
Future<void> loadCaptcha(String baseUrl) async {
if (_isLoadingCaptcha) return;
_isLoadingCaptcha = true;
try {
await ApiService.instance.setBaseUrl(baseUrl);
Map<String, dynamic> config = <String, dynamic>{};
try {
config = await AuthService.instance.getBasicSiteConfig().timeout(
const Duration(seconds: 10),
);
} catch (_) {}
final captchaType = _normalizeCaptchaType(
(config['captcha_type'] ?? config['captchaType'] ?? config['captcha'])
?.toString(),
);
final recaptchaKey = _firstNonEmptyString(config, const [
'captcha_ReCaptchaKey',
'captcha_re_captcha_key',
'captchaReCaptchaKey',
'recaptcha_site_key',
'recaptchaSiteKey',
'recaptcha_key',
'reCaptchaKey',
]);
final turnstileSiteKey = _firstNonEmptyString(config, const [
'turnstile_site_id',
'turnstileSiteId',
'turnstile_site_key',
'turnstileSiteKey',
]);
final capInstanceUrl = _firstNonEmptyString(config, const [
'captcha_cap_instance_url',
'captchaCapInstanceUrl',
'cap_instance_url',
'capInstanceUrl',
]);
final capSiteKey = _firstNonEmptyString(config, const [
'captcha_cap_site_key',
'captchaCapSiteKey',
'cap_site_key',
'capSiteKey',
]);
final capAssetServer = _firstNonEmptyString(config, const [
'captcha_cap_asset_server',
'captchaCapAssetServer',
'cap_asset_server',
'capAssetServer',
]);
final isExternalCaptcha =
captchaType == 'turnstile' ||
captchaType == 'recaptcha' ||
captchaType == 'cap';
if (isExternalCaptcha) {
_captchaType = captchaType;
_recaptchaSiteKey = recaptchaKey;
_turnstileSiteKey = turnstileSiteKey;
_capInstanceUrl = capInstanceUrl;
_capSiteKey = capSiteKey;
_capAssetServer = capAssetServer;
_captchaToken = null;
_captchaImage = null;
_captchaTicket = null;
captchaController.clear();
return;
}
final captcha = await AuthService.instance.getCaptcha();
_captchaType = captchaType.isEmpty ? 'normal' : captchaType;
_recaptchaSiteKey = null;
_turnstileSiteKey = null;
_capInstanceUrl = null;
_capSiteKey = null;
_capAssetServer = null;
_captchaToken = null;
_captchaImage = captcha['image'];
_captchaTicket = captcha['ticket'];
captchaController.clear();
} catch (_) {
clearCaptcha();
} finally {
_isLoadingCaptcha = false;
}
}
/// 重新加载图形验证码图片
Future<void> refreshCaptcha() async {
final server = ServerService.instance.currentServer;
if (server == null) return;
await loadCaptcha(server.baseUrl);
}
/// 跳转到 Web 验证码页面
Future<void> openCaptchaChallenge(BuildContext context) async {
final server = ServerService.instance.currentServer;
final config = captchaWebConfig;
if (server == null || config == null) {
ToastHelper.failure('验证码配置无效');
return;
}
final token = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) =>
CaptchaChallengePage(config: config, baseUrl: server.baseUrl),
),
);
if (token != null && token.isNotEmpty) {
_captchaToken = token;
ToastHelper.success('人机验证完成');
}
}
/// 清空所有验证码状态
void clearCaptcha() {
_captchaType = null;
_recaptchaSiteKey = null;
_turnstileSiteKey = null;
_capInstanceUrl = null;
_capSiteKey = null;
_capAssetServer = null;
_captchaToken = null;
_captchaImage = null;
_captchaTicket = null;
captchaController.clear();
}
/// 获取验证码参数(用于提交登录/注册/忘记密码请求)
///
/// 返回 `{captcha: ..., ticket: ...}` 或空 Map(无需验证码时)。
Map<String, String> getCaptchaParams() {
if (isWebCaptcha) {
if (_captchaToken == null || _captchaToken!.isEmpty) return {};
return {'captcha': _captchaToken!, 'ticket': _captchaToken!};
}
final userInput = captchaController.text.trim();
if (userInput.isEmpty &&
(_captchaTicket == null || _captchaTicket!.isEmpty)) {
return {};
}
return {'captcha': userInput, 'ticket': _captchaTicket ?? ''};
}
/// Web 验证码是否已通过
bool get isWebCaptchaVerified =>
!isWebCaptcha || (_captchaToken != null && _captchaToken!.isNotEmpty);
/// 构建验证码输入 Widget
Widget buildCaptchaInput(BuildContext context) {
if (isWebCaptcha) {
final config = captchaWebConfig;
final displayName = config?.displayName ?? '人机验证';
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
OutlinedButton.icon(
onPressed: _isLoadingCaptcha
? null
: () => openCaptchaChallenge(context),
icon: Icon(
_captchaToken == null
? Icons.verified_user_outlined
: Icons.verified,
),
label: Text(
_captchaToken == null
? '点击完成 $displayName'
: '$displayName 已完成,点击重新验证',
),
),
const SizedBox(height: 8),
Text(
'当前验证码类型:$displayName',
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
),
],
);
}
Widget captchaPreview;
if (_isLoadingCaptcha) {
captchaPreview = const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
);
} else if (_captchaImage != null && _captchaImage!.isNotEmpty) {
try {
final base64Part = _captchaImage!.contains(',')
? _captchaImage!.split(',').last
: _captchaImage!;
captchaPreview = Image.memory(
base64Decode(base64Part),
fit: BoxFit.contain,
gaplessPlayback: true,
);
} catch (_) {
captchaPreview = const Text('刷新');
}
} else {
captchaPreview = const Text('刷新');
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextFormField(
controller: captchaController,
textInputAction: TextInputAction.done,
decoration: const InputDecoration(
labelText: '验证码',
hintText: '请输入验证码',
prefixIcon: Icon(Icons.verified_user_outlined),
),
validator: (value) {
final needCaptcha =
_captchaTicket != null && _captchaTicket!.isNotEmpty;
if (needCaptcha && (value == null || value.trim().isEmpty)) {
return '请输入验证码';
}
return null;
},
),
),
const SizedBox(width: 12),
InkWell(
onTap: _isLoadingCaptcha ? null : refreshCaptcha,
borderRadius: BorderRadius.circular(8),
child: Container(
width: 130,
height: 56,
alignment: Alignment.center,
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(8),
),
child: captchaPreview,
),
),
],
);
}
String _normalizeCaptchaType(String? rawType) {
final value = (rawType ?? '').trim().toLowerCase();
if (value.isEmpty) return 'normal';
if (value == 'image' || value == 'graphic' || value == 'captcha') {
return 'normal';
}
if (value == 'recaptcha_v2' ||
value == 'recaptchav2' ||
value == 'google' ||
value == 'google_recaptcha' ||
value == 'google-recaptcha') {
return 'recaptcha';
}
if (value == 'cloudflare_turnstile' || value == 'cloudflare-turnstile') {
return 'turnstile';
}
return value;
}
String? _firstNonEmptyString(Map<String, dynamic> source, List<String> keys) {
for (final key in keys) {
final value = source[key];
if (value == null) continue;
final text = value.toString().trim();
if (text.isNotEmpty) return text;
}
return null;
}
}
+45 -14
View File
@@ -4,7 +4,6 @@ import '../core/utils/file_utils.dart';
/// 文件服务
class FileService {
/// 列出文件
Future<Map<String, dynamic>> listFiles({
required String uri,
@@ -23,12 +22,38 @@ class FileService {
'next_page_token': nextPageToken,
};
final response = await ApiService.instance
.get<Map<String, dynamic>>('/file', queryParameters: params);
final response = await ApiService.instance.get<Map<String, dynamic>>(
'/file',
queryParameters: params,
);
return response;
}
/// 按 Cloudreve V4 预设分类列出文件。
///
/// category 可用值:image / video / audio / document
Future<Map<String, dynamic>> listFilesByCategory({
required String category,
int page = 0,
int? pageSize,
String? orderBy,
String? orderDirection,
String? nextPageToken,
}) {
final normalizedCategory = category.trim().toLowerCase();
final categoryUri = 'cloudreve://my?category=$normalizedCategory';
return listFiles(
uri: categoryUri,
page: page,
pageSize: pageSize,
orderBy: orderBy,
orderDirection: orderDirection,
nextPageToken: nextPageToken,
);
}
/// 创建文件/文件夹
Future<Map<String, dynamic>> createFile({
required String uri,
@@ -43,8 +68,10 @@ class FileService {
'metadata': ?metadata,
};
final response = await ApiService.instance
.post<Map<String, dynamic>>('/file/create', data: data);
final response = await ApiService.instance.post<Map<String, dynamic>>(
'/file/create',
data: data,
);
return response;
}
@@ -150,9 +177,7 @@ class FileService {
}
/// 恢复文件(从回收站)
Future<void> restoreFiles({
required List<String> uris,
}) async {
Future<void> restoreFiles({required List<String> uris}) async {
final data = <String, dynamic>{
'uris': uris.map((uri) => FileUtils.toCloudreveUri(uri)).toList(),
};
@@ -171,8 +196,10 @@ class FileService {
'page_size': pageSize,
};
final response = await ApiService.instance
.get<Map<String, dynamic>>('/file', queryParameters: params);
final response = await ApiService.instance.get<Map<String, dynamic>>(
'/file',
queryParameters: params,
);
return response;
}
@@ -195,8 +222,10 @@ class FileService {
'page_size': pageSize,
};
final response = await ApiService.instance
.get<Map<String, dynamic>>('/file', queryParameters: params);
final response = await ApiService.instance.get<Map<String, dynamic>>(
'/file',
queryParameters: params,
);
AppLogger.d('Search files ---------> : $response');
return response;
@@ -216,8 +245,10 @@ class FileService {
'folder_summary': folderSummary,
};
final response = await ApiService.instance
.get<Map<String, dynamic>>('/file/info', queryParameters: params);
final response = await ApiService.instance.get<Map<String, dynamic>>(
'/file/info',
queryParameters: params,
);
AppLogger.d("getFileInfo --> $response");
return response;
}