Android Feature Optimization and Updates
Build Android Release / Build Android (push) Failing after 7s
Build Android Release / Build Android (push) Failing after 7s
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user