fix:对齐金值,主要注销,一键登录等方面修改
This commit is contained in:
1 parent
e29ed0a2c8
commit
6997a65c96
37 files changed
+3442
-514
No files matched your search
@@ -1,92 +1,143 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../../common/network/auth_interceptor.dart';
|
||||
import '../../../common/network/dio_provider.dart';
|
||||
import '../../../common/storage/secure_identity_storage.dart';
|
||||
import '../../../common/storage/storage_keys.dart';
|
||||
import '../../../common/storage/storage_service.dart';
|
||||
import '../../../common/network/dio_provider.dart';
|
||||
import '../data/auth_models.dart';
|
||||
import '../data/auth_api.dart';
|
||||
import '../data/one_key_login_api.dart';
|
||||
import '../data/auth_models.dart';
|
||||
|
||||
const _legacyMockLoggedInKey = 'jinzhi_mock_logged_in';
|
||||
|
||||
final authProvider =
|
||||
StateNotifierProvider<AuthController, AsyncValue<AuthState>>(
|
||||
(ref) => AuthController(ref.read(dioProvider)),
|
||||
);
|
||||
StateNotifierProvider<AuthController, AsyncValue<AuthState>>((ref) {
|
||||
return AuthController(
|
||||
ref.read(dioProvider),
|
||||
identityStorage: ref.read(secureIdentityStorageProvider),
|
||||
preferences: StorageService.to.preferences,
|
||||
);
|
||||
});
|
||||
|
||||
class AuthController extends StateNotifier<AsyncValue<AuthState>> {
|
||||
AuthController(this._dio) : super(const AsyncLoading()) {
|
||||
_bootstrap();
|
||||
AuthController(
|
||||
this._dio, {
|
||||
required SecureIdentityStorage identityStorage,
|
||||
required SharedPreferences preferences,
|
||||
bool autoBootstrap = true,
|
||||
}) : _identityStorage = identityStorage,
|
||||
_preferences = preferences,
|
||||
super(const AsyncLoading()) {
|
||||
if (autoBootstrap) _bootstrap();
|
||||
}
|
||||
|
||||
final Dio _dio;
|
||||
final SecureIdentityStorage _identityStorage;
|
||||
final SharedPreferences _preferences;
|
||||
|
||||
AuthApi get _api => AuthApi(_dio);
|
||||
|
||||
Future<void> retryBootstrap() => _bootstrap();
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
final token = await loadJwt();
|
||||
state = const AsyncLoading();
|
||||
final token = await _identityStorage.readJwt();
|
||||
if (token == null || token.isEmpty) {
|
||||
state = const AsyncData(GuestAuthState());
|
||||
return;
|
||||
}
|
||||
final phone = StorageService.to.getString(storageJinzhiMockLoggedIn) ?? '';
|
||||
state = AsyncData(
|
||||
LoggedInAuthState(
|
||||
user: JinzhiUser(
|
||||
phone: phone.isEmpty ? '13800000000' : phone,
|
||||
nickname: '金值用户',
|
||||
createdAt: DateTime(2026, 6, 18),
|
||||
),
|
||||
token: token,
|
||||
),
|
||||
);
|
||||
try {
|
||||
final user = await _api.getProfile();
|
||||
await _persistProfile(user);
|
||||
state = AsyncData(LoggedInAuthState(user: user, token: token));
|
||||
} catch (error, stackTrace) {
|
||||
if (_isUnauthorized(error)) {
|
||||
await _clearLocalSession();
|
||||
state = const AsyncData(GuestAuthState());
|
||||
return;
|
||||
}
|
||||
final cached = _canUseCachedProfile(error) ? _readCachedProfile() : null;
|
||||
if (cached != null) {
|
||||
await _persistProfile(cached);
|
||||
state = AsyncData(LoggedInAuthState(user: cached, token: token));
|
||||
} else {
|
||||
state = AsyncError(error, stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendCode(String phone) async {
|
||||
await AuthApi(_dio).sendSmsCode(phone);
|
||||
}
|
||||
Future<void> sendCode(String phone) => _api.sendSmsCode(phone);
|
||||
|
||||
Future<void> verifyCode(String phone, String code) async {
|
||||
final reply = await AuthApi(_dio).verifySmsCode(phone, code);
|
||||
await saveJwt(reply.accessToken);
|
||||
await StorageService.to.setString(storageJinzhiMockLoggedIn, reply.phone);
|
||||
state = AsyncData(
|
||||
LoggedInAuthState(
|
||||
user: JinzhiUser(
|
||||
phone: reply.phone,
|
||||
nickname: '金值用户',
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
token: reply.accessToken,
|
||||
),
|
||||
);
|
||||
final reply = await _api.verifySmsCode(phone, code);
|
||||
await _persistLogin(reply);
|
||||
}
|
||||
|
||||
/// Test environment reuses Yanting's one-key verification endpoint until
|
||||
/// 金值 has its own account contract.
|
||||
Future<void> verifyOneKeyLogin(String loginToken) async {
|
||||
final reply = await OneKeyLoginApi(_dio).verify(loginToken);
|
||||
await saveJwt(reply.token);
|
||||
await StorageService.to.setString(storageJinzhiMockLoggedIn, reply.phone);
|
||||
final reply = await _api.verifyOneKeyLogin(loginToken);
|
||||
await _persistLogin(reply);
|
||||
}
|
||||
|
||||
Future<void> _persistLogin(AuthLoginResult reply) async {
|
||||
await _identityStorage.saveJwt(reply.accessToken);
|
||||
await _persistProfile(reply.user);
|
||||
state = AsyncData(
|
||||
LoggedInAuthState(
|
||||
user: JinzhiUser(
|
||||
phone: reply.phone,
|
||||
nickname: '金值用户',
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
token: reply.token,
|
||||
),
|
||||
LoggedInAuthState(user: reply.user, token: reply.accessToken),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await clearJwt();
|
||||
await StorageService.to.setString(storageJinzhiMockLoggedIn, '');
|
||||
await _clearLocalSession();
|
||||
state = const AsyncData(GuestAuthState());
|
||||
}
|
||||
|
||||
Future<void> deleteAccount([String? code]) async {
|
||||
await logout();
|
||||
Future<void> deleteAccount(String code) async {
|
||||
final normalized = code.trim();
|
||||
if (normalized.isEmpty) {
|
||||
throw ArgumentError.value(code, 'code', 'must not be empty');
|
||||
}
|
||||
await _api.deleteAccount(normalized);
|
||||
await _clearLocalSession();
|
||||
state = const AsyncData(GuestAuthState());
|
||||
}
|
||||
|
||||
Future<void> _persistProfile(JinzhiUser user) async {
|
||||
await _preferences.setString(storageUserId, user.id);
|
||||
await _preferences.setString(storageUserPhone, user.phone);
|
||||
await _preferences.setString(storageUserCreatedAt, user.createdAt);
|
||||
await _preferences.remove(_legacyMockLoggedInKey);
|
||||
}
|
||||
|
||||
JinzhiUser? _readCachedProfile() {
|
||||
final id = _preferences.getString(storageUserId)?.trim() ?? '';
|
||||
final phone =
|
||||
(_preferences.getString(storageUserPhone) ??
|
||||
_preferences.getString(_legacyMockLoggedInKey) ??
|
||||
'')
|
||||
.trim();
|
||||
final createdAt =
|
||||
_preferences.getString(storageUserCreatedAt)?.trim() ?? '';
|
||||
if (id.isEmpty && phone.isEmpty) return null;
|
||||
return JinzhiUser(id: id, phone: phone, createdAt: createdAt);
|
||||
}
|
||||
|
||||
Future<void> _clearLocalSession() async {
|
||||
await _identityStorage.clearJwt();
|
||||
await _preferences.remove(storageUserId);
|
||||
await _preferences.remove(storageUserPhone);
|
||||
await _preferences.remove(storageUserCreatedAt);
|
||||
await _preferences.remove(_legacyMockLoggedInKey);
|
||||
}
|
||||
|
||||
bool get isLoggedIn => state.valueOrNull is LoggedInAuthState;
|
||||
}
|
||||
|
||||
bool _isUnauthorized(Object error) =>
|
||||
error is DioException && error.response?.statusCode == 401;
|
||||
|
||||
bool _canUseCachedProfile(Object error) {
|
||||
if (error is! DioException) return false;
|
||||
final status = error.response?.statusCode;
|
||||
return status == null || status == 408 || status == 429 || status >= 500;
|
||||
}
|
||||
@@ -24,6 +24,9 @@ abstract interface class OneKeyLoginGateway {
|
||||
|
||||
void setWidgetCallback(OneKeyLoginCallback? callback);
|
||||
|
||||
/// 仅在用户点按一键登录后调用,预热运营商授权页。
|
||||
Future<Object?> accelerate();
|
||||
|
||||
Future<void> requestLogin(UMCustomModel uiConfig);
|
||||
|
||||
void closeAuthPage();
|
||||
@@ -65,6 +68,11 @@ class UmengOneKeyLoginGateway implements OneKeyLoginGateway {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Object?> accelerate() {
|
||||
return UmengVerifySdk.accelerateLoginPageWithTimeout(3);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> requestLogin(UMCustomModel uiConfig) async {
|
||||
await UmengVerifySdk.getLoginTokenWithTimeout(3, uiConfig);
|
||||
@@ -137,15 +145,25 @@ class OneKeyLoginSdkSession {
|
||||
return _result.future;
|
||||
}
|
||||
|
||||
// Availability checking already pre-accelerates the carrier page. Start
|
||||
// the token request directly here; the SDK performs its own preflight when
|
||||
// needed and reports its timeout/error through the registered callback.
|
||||
// 预热必须由用户显式点击触发;不在启动或隐私同意阶段调用它。
|
||||
unawaited(_startNativeLogin(uiConfig));
|
||||
return _result.future;
|
||||
}
|
||||
|
||||
Future<void> _startNativeLogin(UMCustomModel uiConfig) async {
|
||||
try {
|
||||
// 与 jinzhi-flutter-app 保持一致。未预热时 SDK 会在此处隐式预热,
|
||||
// 在部分 Android 机型上更容易超时或直接回调失败。
|
||||
final preheat = await _gateway.accelerate();
|
||||
if (kDebugMode && preheat is Map && preheat['ret'] != null) {
|
||||
// `ret` 是运营商预热失败码;插件原生层未将它输出到 logcat。
|
||||
// 预热失败不阻断接下来的 getLoginToken,SDK 会自行再试一次。
|
||||
debugPrint(
|
||||
'OneKeyLogin preheat failed: vendor=${preheat['vendor']}, '
|
||||
'ret=${preheat['ret']}',
|
||||
);
|
||||
}
|
||||
if (_terminal) return;
|
||||
_gateway.setTokenCallback(_handleTokenResult);
|
||||
_gateway.setWidgetCallback((Object? _) {});
|
||||
_gateway.setUiCallback(_handleUiResult);
|
||||
@@ -186,6 +204,15 @@ class OneKeyLoginSdkSession {
|
||||
|
||||
void _handleTokenResult(Object? result) {
|
||||
final code = extractUmengLoginCode(result);
|
||||
if (kDebugMode && !isUmengTokenSuccess(result)) {
|
||||
final map = result is Map ? result : const <Object?, Object?>{};
|
||||
// 本地插件原先丢弃了 onTokenFailed 的 ret;只记录诊断字段,绝不记录 token。
|
||||
debugPrint(
|
||||
'OneKeyLogin Android/iOS SDK result: code=$code, '
|
||||
'msg=${map['msg']}, vendor=${map['vendorName']}, '
|
||||
'carrierError=${map['carrierFailedResultData']}',
|
||||
);
|
||||
}
|
||||
// On iOS, 700002 only means the user tapped the authorization page's
|
||||
// login button. It is followed by a separate 600000 (or 600xxx) token
|
||||
// callback, so completing the session here discards that callback.
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../common/network/dio_provider.dart';
|
||||
import '../../../models/pb/jinzhi/v1/account.pb.dart' as pb;
|
||||
import 'auth_models.dart';
|
||||
|
||||
/// 金值账号服务的 protobuf HTTP 契约。
|
||||
///
|
||||
/// 这个轻量编解码只覆盖短信登录需要的字段,避免把整套历史生成模型带入
|
||||
/// 当前重建中的应用;字段号与金值 `account.proto` 保持一致。
|
||||
/// 金值账户的 protobuf-over-HTTP 客户端。
|
||||
class AuthApi {
|
||||
const AuthApi(this._dio);
|
||||
|
||||
@@ -17,124 +13,82 @@ class AuthApi {
|
||||
Future<int> sendSmsCode(String phone) async {
|
||||
final response = await _dio.post<List<int>>(
|
||||
'/api/v1/auth/sms/send',
|
||||
// Dio may JSON-encode a List<int> as `[10,11,...]`. The API expects
|
||||
// the raw protobuf bytes, so preserve the binary payload explicitly.
|
||||
data: Uint8List.fromList(_ProtoWriter.stringField(1, phone)),
|
||||
data: pb.SendSmsCodeRequest(phone: phone).writeToBuffer(),
|
||||
options: protobufOptions(),
|
||||
);
|
||||
return _ProtoMessage(response.data ?? const <int>[]).integer(1);
|
||||
return pb.SendSmsCodeReply.fromBuffer(
|
||||
response.data ?? const <int>[],
|
||||
).expiresIn;
|
||||
}
|
||||
|
||||
Future<AuthLoginResult> verifySmsCode(String phone, String code) async {
|
||||
final response = await _dio.post<List<int>>(
|
||||
'/api/v1/auth/sms/verify',
|
||||
data: Uint8List.fromList([
|
||||
..._ProtoWriter.stringField(1, phone),
|
||||
..._ProtoWriter.stringField(2, code),
|
||||
]),
|
||||
data: pb.VerifySmsCodeRequest(phone: phone, code: code).writeToBuffer(),
|
||||
options: protobufOptions(),
|
||||
);
|
||||
final reply = _ProtoMessage(response.data ?? const <int>[]);
|
||||
final accessToken = reply.string(1).trim();
|
||||
if (accessToken.isEmpty) {
|
||||
throw const FormatException('登录响应缺少 access_token');
|
||||
return AuthLoginResult.fromProto(
|
||||
pb.VerifySmsCodeReply.fromBuffer(response.data ?? const <int>[]),
|
||||
);
|
||||
}
|
||||
|
||||
Future<AuthLoginResult> verifyOneKeyLogin(String loginToken) async {
|
||||
final response = await _dio.post<List<int>>(
|
||||
'/api/v1/auth/onekey/verify',
|
||||
data: pb.VerifyOneKeyLoginRequest(loginToken: loginToken).writeToBuffer(),
|
||||
options: protobufOptions(),
|
||||
);
|
||||
return AuthLoginResult.fromProto(
|
||||
pb.VerifySmsCodeReply.fromBuffer(response.data ?? const <int>[]),
|
||||
);
|
||||
}
|
||||
|
||||
Future<JinzhiUser> getProfile() async {
|
||||
final response = await _dio.get<List<int>>(
|
||||
'/api/v1/me',
|
||||
options: protobufOptions(),
|
||||
);
|
||||
final reply = pb.ProfileReply.fromBuffer(response.data ?? const <int>[]);
|
||||
if (!reply.hasUser()) {
|
||||
throw const FormatException('账号资料响应缺少 user');
|
||||
}
|
||||
final user = _ProtoMessage(reply.bytes(3));
|
||||
return AuthLoginResult(
|
||||
accessToken: accessToken,
|
||||
phone: user.string(2).trim().isEmpty ? phone : user.string(2).trim(),
|
||||
return _userFromProto(reply.user);
|
||||
}
|
||||
|
||||
Future<void> deleteAccount(String smsCode) async {
|
||||
await _dio.delete<List<int>>(
|
||||
'/api/v1/me',
|
||||
data: pb.DeleteAccountRequest(smsCode: smsCode).writeToBuffer(),
|
||||
options: protobufOptions(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AuthLoginResult {
|
||||
const AuthLoginResult({required this.accessToken, required this.phone});
|
||||
const AuthLoginResult({
|
||||
required this.accessToken,
|
||||
required this.expiresIn,
|
||||
required this.user,
|
||||
});
|
||||
|
||||
final String accessToken;
|
||||
final String phone;
|
||||
}
|
||||
final int expiresIn;
|
||||
final JinzhiUser user;
|
||||
|
||||
class _ProtoWriter {
|
||||
static List<int> stringField(int field, String value) => [
|
||||
..._varint(field << 3 | 2),
|
||||
..._varint(utf8.encode(value).length),
|
||||
...utf8.encode(value),
|
||||
];
|
||||
|
||||
static List<int> _varint(int value) {
|
||||
final bytes = <int>[];
|
||||
var remaining = value;
|
||||
while (remaining > 0x7f) {
|
||||
bytes.add(remaining & 0x7f | 0x80);
|
||||
remaining >>= 7;
|
||||
factory AuthLoginResult.fromProto(pb.VerifySmsCodeReply reply) {
|
||||
if (reply.accessToken.trim().isEmpty || !reply.hasUser()) {
|
||||
throw const FormatException('登录响应缺少 access_token 或 user');
|
||||
}
|
||||
bytes.add(remaining);
|
||||
return bytes;
|
||||
return AuthLoginResult(
|
||||
accessToken: reply.accessToken.trim(),
|
||||
expiresIn: reply.expiresIn,
|
||||
user: _userFromProto(reply.user),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProtoMessage {
|
||||
_ProtoMessage(List<int> bytes) : _fields = _parse(bytes);
|
||||
|
||||
final Map<int, Object> _fields;
|
||||
|
||||
int integer(int field) => _fields[field] is int ? _fields[field]! as int : 0;
|
||||
|
||||
String string(int field) => _fields[field] is List<int>
|
||||
? utf8.decode(_fields[field]! as List<int>, allowMalformed: true)
|
||||
: '';
|
||||
|
||||
List<int> bytes(int field) => _fields[field] is List<int>
|
||||
? _fields[field]! as List<int>
|
||||
: const <int>[];
|
||||
|
||||
static Map<int, Object> _parse(List<int> bytes) {
|
||||
final fields = <int, Object>{};
|
||||
var cursor = 0;
|
||||
while (cursor < bytes.length) {
|
||||
final tag = _readVarint(bytes, cursor);
|
||||
if (tag == null) break;
|
||||
cursor = tag.next;
|
||||
final field = tag.value >> 3;
|
||||
switch (tag.value & 7) {
|
||||
case 0:
|
||||
final value = _readVarint(bytes, cursor);
|
||||
if (value == null) return fields;
|
||||
fields[field] = value.value;
|
||||
cursor = value.next;
|
||||
case 2:
|
||||
final length = _readVarint(bytes, cursor);
|
||||
if (length == null || length.next + length.value > bytes.length) {
|
||||
return fields;
|
||||
}
|
||||
fields[field] = bytes.sublist(
|
||||
length.next,
|
||||
length.next + length.value,
|
||||
);
|
||||
cursor = length.next + length.value;
|
||||
default:
|
||||
return fields;
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
static _ProtoValue? _readVarint(List<int> bytes, int cursor) {
|
||||
var value = 0;
|
||||
var shift = 0;
|
||||
while (cursor < bytes.length && shift <= 63) {
|
||||
final byte = bytes[cursor++];
|
||||
value |= (byte & 0x7f) << shift;
|
||||
if (byte & 0x80 == 0) return _ProtoValue(value, cursor);
|
||||
shift += 7;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class _ProtoValue {
|
||||
const _ProtoValue(this.value, this.next);
|
||||
|
||||
final int value;
|
||||
final int next;
|
||||
}
|
||||
JinzhiUser _userFromProto(pb.UserInfo user) => JinzhiUser(
|
||||
id: user.id.trim(),
|
||||
phone: user.phone.trim(),
|
||||
createdAt: user.createdAt.trim(),
|
||||
);
|
||||
@@ -1,13 +1,15 @@
|
||||
class JinzhiUser {
|
||||
const JinzhiUser({
|
||||
required this.id,
|
||||
required this.phone,
|
||||
required this.nickname,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String phone;
|
||||
final String nickname;
|
||||
final DateTime createdAt;
|
||||
final String createdAt;
|
||||
|
||||
String get nickname => '金值用户';
|
||||
}
|
||||
|
||||
sealed class AuthState {
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../common/network/dio_provider.dart';
|
||||
|
||||
/// Compatible with Yanting's protobuf `/api/v1/auth/onekey/verify` contract.
|
||||
class OneKeyLoginApi {
|
||||
OneKeyLoginApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
Future<OneKeyLoginReply> verify(String loginToken) async {
|
||||
final bytes = utf8.encode(loginToken);
|
||||
final request = Uint8List.fromList([
|
||||
0x0A,
|
||||
..._varint(bytes.length),
|
||||
...bytes,
|
||||
]);
|
||||
final response = await _dio.post<List<int>>(
|
||||
'/api/v1/auth/onekey/verify',
|
||||
data: request,
|
||||
options: protobufOptions(),
|
||||
);
|
||||
return _decode(Uint8List.fromList(response.data ?? const []));
|
||||
}
|
||||
|
||||
OneKeyLoginReply _decode(Uint8List data) {
|
||||
final fields = _fields(data);
|
||||
final token = utf8.decode(fields[1] ?? const []);
|
||||
final user = _fields(Uint8List.fromList(fields[3] ?? const []));
|
||||
if (token.isEmpty) throw StateError('一键登录服务未返回登录凭证');
|
||||
return OneKeyLoginReply(
|
||||
token: token,
|
||||
phone: utf8.decode(user[2] ?? const []),
|
||||
);
|
||||
}
|
||||
|
||||
Map<int, List<int>> _fields(Uint8List data) {
|
||||
final result = <int, List<int>>{};
|
||||
var i = 0;
|
||||
while (i < data.length) {
|
||||
final tag = _readVarint(data, i);
|
||||
i = tag.$2;
|
||||
final wireType = tag.$1 & 7;
|
||||
if (wireType == 0) {
|
||||
i = _readVarint(data, i).$2;
|
||||
continue;
|
||||
}
|
||||
if (wireType != 2) {
|
||||
throw const FormatException('Unsupported protobuf field');
|
||||
}
|
||||
final length = _readVarint(data, i);
|
||||
i = length.$2;
|
||||
final end = i + length.$1;
|
||||
if (end > data.length) {
|
||||
throw const FormatException('Invalid protobuf payload');
|
||||
}
|
||||
result[tag.$1 >> 3] = data.sublist(i, end);
|
||||
i = end;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
(int, int) _readVarint(Uint8List data, int index) {
|
||||
var value = 0;
|
||||
var shift = 0;
|
||||
while (index < data.length) {
|
||||
final byte = data[index++];
|
||||
value |= (byte & 0x7f) << shift;
|
||||
if (byte & 0x80 == 0) return (value, index);
|
||||
shift += 7;
|
||||
}
|
||||
throw const FormatException('Invalid protobuf varint');
|
||||
}
|
||||
|
||||
List<int> _varint(int value) {
|
||||
final result = <int>[];
|
||||
while (value > 0x7f) {
|
||||
result.add((value & 0x7f) | 0x80);
|
||||
value >>= 7;
|
||||
}
|
||||
result.add(value);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class OneKeyLoginReply {
|
||||
const OneKeyLoginReply({required this.token, required this.phone});
|
||||
final String token;
|
||||
final String phone;
|
||||
}
|
||||
@@ -31,7 +31,20 @@ class DeleteAccountPage extends ConsumerWidget {
|
||||
),
|
||||
error: (e, _) => Scaffold(
|
||||
backgroundColor: cs.background,
|
||||
body: Center(child: Text('加载失败:$e')),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('账号状态加载失败'),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: () =>
|
||||
ref.read(authProvider.notifier).retryBootstrap(),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
data: (authState) {
|
||||
if (authState is! LoggedInAuthState) {
|
||||
@@ -669,7 +682,9 @@ class _VerifySheetState extends ConsumerState<_VerifySheet> {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return Padding(
|
||||
// 键盘弹出时 sheet 跟随上移
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||
@@ -715,11 +730,7 @@ class _VerifySheetState extends ConsumerState<_VerifySheet> {
|
||||
return [
|
||||
Text(
|
||||
'正在清除账户数据,请稍候',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.55,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
style: TextStyle(fontSize: 13, height: 1.55, color: cs.mutedForeground),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Center(
|
||||
@@ -737,11 +748,7 @@ class _VerifySheetState extends ConsumerState<_VerifySheet> {
|
||||
return [
|
||||
Text(
|
||||
'为保障账户安全,注销前需验证您的手机号。验证码将发送至 ${_maskPhone(widget.phone)}。',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.55,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
style: TextStyle(fontSize: 13, height: 1.55, color: cs.mutedForeground),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
@@ -768,11 +775,7 @@ class _VerifySheetState extends ConsumerState<_VerifySheet> {
|
||||
return [
|
||||
Text(
|
||||
'验证码已发送至 ${_maskPhone(widget.phone)}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.55,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
style: TextStyle(fontSize: 13, height: 1.55, color: cs.mutedForeground),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_PinInput(
|
||||
|
||||
@@ -27,23 +27,14 @@ class MallEntryController {
|
||||
if (config?.isVisible != true || !context.mounted) {
|
||||
return MallEntryOpenResult.unavailable;
|
||||
}
|
||||
final clickId = await _ref
|
||||
config = await _ref
|
||||
.read(mallTabConfigProvider.notifier)
|
||||
.recordClick();
|
||||
if (clickId == null || !context.mounted) {
|
||||
.recordClick(request: request);
|
||||
if (!config.isVisible || !context.mounted) {
|
||||
return MallEntryOpenResult.unavailable;
|
||||
}
|
||||
_ref
|
||||
.read(mallClickAttributionProvider.notifier)
|
||||
.state = MallClickAttribution(
|
||||
adId: config!.adId,
|
||||
trackingId: config.trackingId,
|
||||
clickId: clickId,
|
||||
);
|
||||
final uri = config.attributedUri(
|
||||
clickId: clickId,
|
||||
isDark: Theme.of(context).brightness == Brightness.dark,
|
||||
entry: request,
|
||||
);
|
||||
if (uri == null) return MallEntryOpenResult.unavailable;
|
||||
// The bottom mall entry is always resident. Server open-mode metadata is
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../../common/network/dio_provider.dart';
|
||||
import '../../../models/pb/jinzhi/v1/ads.pb.dart' as pb;
|
||||
|
||||
/// 金值商城广告位的公共契约;商城是否展示完全由服务端配置决定。
|
||||
/// 标识打开商城的宿主项目。
|
||||
const mallSourceApp = 'jinzhi';
|
||||
|
||||
/// The native entry that opened the resident mall. H5 uses this context to
|
||||
/// choose the appropriate landing flow while sharing the same WebView host.
|
||||
class MallEntryRequest {
|
||||
const MallEntryRequest({
|
||||
required this.sourceEntry,
|
||||
@@ -45,11 +42,15 @@ class MallTabConfig {
|
||||
this.title = '商城',
|
||||
this.adId = '',
|
||||
this.trackingId = '',
|
||||
this.clickId = '',
|
||||
this.openMode = 'in_app',
|
||||
this.entryPromoEnabled = false,
|
||||
this.entryBadgeText = '',
|
||||
this.entryTipTitle = '',
|
||||
this.entryTipBody = '',
|
||||
this.quoteCtaEnabled = false,
|
||||
this.entryRequest = MallEntryRequest.home,
|
||||
this.allowedHosts = const <String>{},
|
||||
});
|
||||
|
||||
const MallTabConfig.disabled() : this(enabled: false, url: '');
|
||||
@@ -59,11 +60,15 @@ class MallTabConfig {
|
||||
final String title;
|
||||
final String adId;
|
||||
final String trackingId;
|
||||
final String clickId;
|
||||
final String openMode;
|
||||
final bool entryPromoEnabled;
|
||||
final String entryBadgeText;
|
||||
final String entryTipTitle;
|
||||
final String entryTipBody;
|
||||
final bool quoteCtaEnabled;
|
||||
final MallEntryRequest entryRequest;
|
||||
final Set<String> allowedHosts;
|
||||
|
||||
bool get isVisible => enabled && initialUri != null;
|
||||
bool get opensInApp => openMode == 'in_app';
|
||||
@@ -74,52 +79,80 @@ class MallTabConfig {
|
||||
entryPromoEnabled &&
|
||||
(entryTipTitle.trim().isNotEmpty || entryTipBody.trim().isNotEmpty);
|
||||
|
||||
Uri? get initialUri => attributedUri(clickId: '');
|
||||
String get from {
|
||||
final value = Uri.tryParse(url.trim())?.queryParameters['from']?.trim();
|
||||
return value == null || value.isEmpty ? mallSourceApp : value;
|
||||
}
|
||||
|
||||
Uri? get initialUri => attributedUri();
|
||||
|
||||
Uri? attributedUri({
|
||||
required String clickId,
|
||||
String? clickId,
|
||||
bool isDark = false,
|
||||
MallEntryRequest entry = MallEntryRequest.home,
|
||||
MallEntryRequest? entry,
|
||||
}) {
|
||||
final uri = Uri.tryParse(url.trim());
|
||||
if (!_isAllowed(uri)) return null;
|
||||
final effectiveClickId = clickId ?? this.clickId;
|
||||
final effectiveEntry = entry ?? entryRequest;
|
||||
return uri!.replace(
|
||||
queryParameters: {
|
||||
...uri.queryParameters,
|
||||
'from': uri.queryParameters['from']?.trim().isNotEmpty == true
|
||||
? uri.queryParameters['from']!
|
||||
: mallSourceApp,
|
||||
'source_app': mallSourceApp,
|
||||
'source_entry': entry.sourceEntry,
|
||||
'from': from,
|
||||
'source_app': from,
|
||||
'source_entry': effectiveEntry.sourceEntry,
|
||||
'placement': 'mall_tab',
|
||||
'hook_type': entry.hookType,
|
||||
if (entry.entryScene?.isNotEmpty == true)
|
||||
'entry_scene': entry.entryScene!,
|
||||
if (entry.entryMetal?.isNotEmpty == true)
|
||||
'entry_metal': entry.entryMetal!,
|
||||
'hook_type': effectiveEntry.hookType,
|
||||
'entry_scene': ?effectiveEntry.entryScene,
|
||||
'entry_metal': ?effectiveEntry.entryMetal,
|
||||
'target_app': 'mall',
|
||||
if (adId.isNotEmpty) 'ad_id': adId,
|
||||
if (trackingId.isNotEmpty) 'tracking_id': trackingId,
|
||||
if (clickId.isNotEmpty) 'click_id': clickId,
|
||||
if (clickId.isNotEmpty)
|
||||
if (effectiveClickId.isNotEmpty) 'click_id': effectiveClickId,
|
||||
if (effectiveClickId.isNotEmpty)
|
||||
'ts': DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
'isNight': isDark ? '1' : '0',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
MallTabConfig withClick(
|
||||
String value, {
|
||||
MallEntryRequest request = MallEntryRequest.home,
|
||||
}) => MallTabConfig(
|
||||
enabled: enabled,
|
||||
url: url,
|
||||
title: title,
|
||||
adId: adId,
|
||||
trackingId: trackingId,
|
||||
clickId: value,
|
||||
openMode: openMode,
|
||||
entryPromoEnabled: entryPromoEnabled,
|
||||
entryBadgeText: entryBadgeText,
|
||||
entryTipTitle: entryTipTitle,
|
||||
entryTipBody: entryTipBody,
|
||||
quoteCtaEnabled: quoteCtaEnabled,
|
||||
entryRequest: request,
|
||||
allowedHosts: allowedHosts,
|
||||
);
|
||||
|
||||
static bool _isAllowed(Uri? uri) {
|
||||
if (uri == null || uri.host.isEmpty) return false;
|
||||
if (uri.scheme == 'https') return true;
|
||||
if (uri.scheme.toLowerCase() == 'https') return true;
|
||||
return !kReleaseMode &&
|
||||
uri.scheme == 'http' &&
|
||||
const {'localhost', '127.0.0.1', '::1'}.contains(uri.host);
|
||||
uri.scheme.toLowerCase() == 'http' &&
|
||||
const {
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'::1',
|
||||
}.contains(uri.host.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
class MallTabConfigController extends StateNotifier<MallTabConfig> {
|
||||
MallTabConfigController(this._dio) : super(const MallTabConfig.disabled()) {
|
||||
refresh();
|
||||
MallTabConfigController(this._dio, {bool autoRefresh = true})
|
||||
: super(const MallTabConfig.disabled()) {
|
||||
if (autoRefresh) refresh();
|
||||
}
|
||||
|
||||
final Dio _dio;
|
||||
@@ -138,38 +171,51 @@ class MallTabConfigController extends StateNotifier<MallTabConfig> {
|
||||
queryParameters: const {'placement': 'mall_tab'},
|
||||
options: protobufOptions(),
|
||||
);
|
||||
state = parseMallTabConfigResponse(response.data ?? const []);
|
||||
state = parseMallTabConfigResponse(response.data ?? const <int>[]);
|
||||
if (!state.isVisible) return;
|
||||
await _report('ad_impression');
|
||||
await _report(state, 'ad_impression');
|
||||
} catch (_) {
|
||||
state = const MallTabConfig.disabled();
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> recordClick() async {
|
||||
if (!state.isVisible) return null;
|
||||
final clickId = const Uuid().v4();
|
||||
Future<MallTabConfig> recordClick({
|
||||
MallEntryRequest request = MallEntryRequest.home,
|
||||
}) async {
|
||||
if (!state.isVisible) return state;
|
||||
final clicked = state.withClick(const Uuid().v4(), request: request);
|
||||
state = clicked;
|
||||
try {
|
||||
await _report('ad_click', clickId: clickId);
|
||||
await _report(clicked, 'ad_click');
|
||||
} catch (_) {
|
||||
// 归因上报失败不能阻断商城入口。
|
||||
}
|
||||
return clickId;
|
||||
return clicked;
|
||||
}
|
||||
|
||||
Future<void> _report(String event, {String clickId = ''}) =>
|
||||
_dio.post<List<int>>(
|
||||
'/api/v1/ads/events',
|
||||
data: _ProtoWriter()
|
||||
.string(1, event)
|
||||
.string(2, 'mall_tab')
|
||||
.string(3, state.adId)
|
||||
.string(4, state.trackingId)
|
||||
.string(5, clickId)
|
||||
.integer(6, DateTime.now().millisecondsSinceEpoch)
|
||||
.toBytes(),
|
||||
options: protobufOptions(),
|
||||
);
|
||||
Future<void> _report(MallTabConfig config, String event) async {
|
||||
final response = await _dio.post<List<int>>(
|
||||
'/api/v1/ads/events',
|
||||
data: pb.ReportAdEventRequest(
|
||||
eventName: event,
|
||||
placement: 'mall_tab',
|
||||
adId: config.adId,
|
||||
trackingId: config.trackingId,
|
||||
clickId: config.clickId,
|
||||
clientTsMs: Int64(DateTime.now().millisecondsSinceEpoch),
|
||||
attrs: <String, String>{
|
||||
'source_app': config.from,
|
||||
'source_entry': config.entryRequest.sourceEntry,
|
||||
'hook_type': config.entryRequest.hookType,
|
||||
'entry_scene': ?config.entryRequest.entryScene,
|
||||
'entry_metal': ?config.entryRequest.entryMetal,
|
||||
'open_mode': config.openMode,
|
||||
},
|
||||
).writeToBuffer(),
|
||||
options: protobufOptions(),
|
||||
);
|
||||
pb.ReportAdEventReply.fromBuffer(response.data ?? const <int>[]);
|
||||
}
|
||||
}
|
||||
|
||||
final mallTabConfigProvider =
|
||||
@@ -177,52 +223,21 @@ final mallTabConfigProvider =
|
||||
(ref) => MallTabConfigController(ref.watch(dioProvider)),
|
||||
);
|
||||
|
||||
class MallClickAttribution {
|
||||
const MallClickAttribution({
|
||||
required this.adId,
|
||||
required this.trackingId,
|
||||
required this.clickId,
|
||||
});
|
||||
|
||||
final String adId;
|
||||
final String trackingId;
|
||||
final String clickId;
|
||||
}
|
||||
|
||||
final mallClickAttributionProvider = StateProvider<MallClickAttribution?>(
|
||||
(ref) => null,
|
||||
);
|
||||
|
||||
Map<String, dynamic> buildEnterWebMallBridgeArguments({
|
||||
required Uri currentUri,
|
||||
required MallTabConfig config,
|
||||
required bool logged,
|
||||
required bool isDark,
|
||||
Uri? currentUri,
|
||||
bool isDark = false,
|
||||
String? authToken,
|
||||
String? scene,
|
||||
String? sourceApp,
|
||||
MallClickAttribution? attribution,
|
||||
}) {
|
||||
final query = currentUri.queryParameters;
|
||||
final resolvedSourceApp = sourceApp?.trim().isNotEmpty == true
|
||||
? sourceApp!.trim()
|
||||
: mallSourceApp;
|
||||
final arguments = <String, dynamic>{
|
||||
...query,
|
||||
'from': resolvedSourceApp,
|
||||
'source_app': resolvedSourceApp,
|
||||
'source_entry': query['source_entry'] ?? 'mall_tab',
|
||||
'placement': query['placement'] ?? 'mall_tab',
|
||||
'hook_type': query['hook_type'] ?? 'mall_tab',
|
||||
'target_app': query['target_app'] ?? 'mall',
|
||||
...?currentUri?.queryParameters,
|
||||
...?config.attributedUri(isDark: isDark)?.queryParameters,
|
||||
'from': config.from,
|
||||
'logged': logged,
|
||||
'isNight': isDark ? '1' : '0',
|
||||
};
|
||||
final adId = attribution?.adId.trim() ?? '';
|
||||
if (adId.isNotEmpty) arguments['ad_id'] = adId;
|
||||
final trackingId = attribution?.trackingId.trim() ?? '';
|
||||
if (trackingId.isNotEmpty) arguments['tracking_id'] = trackingId;
|
||||
final clickId = attribution?.clickId.trim() ?? '';
|
||||
if (clickId.isNotEmpty) arguments['click_id'] = clickId;
|
||||
final token = authToken?.trim() ?? '';
|
||||
if (token.isNotEmpty) {
|
||||
arguments['auth_token'] = token;
|
||||
@@ -230,119 +245,52 @@ Map<String, dynamic> buildEnterWebMallBridgeArguments({
|
||||
}
|
||||
final resolvedScene = scene?.trim() ?? '';
|
||||
if (resolvedScene.isNotEmpty) arguments['scene'] = resolvedScene;
|
||||
final resolvedSourceApp = sourceApp?.trim() ?? '';
|
||||
if (resolvedSourceApp.isNotEmpty) {
|
||||
arguments['from'] = resolvedSourceApp;
|
||||
arguments['source_app'] = resolvedSourceApp;
|
||||
}
|
||||
return arguments;
|
||||
}
|
||||
|
||||
MallTabConfig parseMallTabConfigResponse(List<int> bytes) {
|
||||
final reply = pb.GetAdReply.fromBuffer(bytes);
|
||||
if (!reply.enabled || !reply.hasAd()) {
|
||||
return const MallTabConfig.disabled();
|
||||
}
|
||||
final creative = reply.ad;
|
||||
final url = _firstAllowedUrl(creative.trackedUrl, creative.h5Url);
|
||||
if (url == null) return const MallTabConfig.disabled();
|
||||
final promotion = creative.hasMallPromotion() ? creative.mallPromotion : null;
|
||||
return MallTabConfig(
|
||||
enabled: true,
|
||||
url: url,
|
||||
title: _nonEmpty(creative.title, '商城'),
|
||||
adId: creative.adId,
|
||||
trackingId: creative.trackingId,
|
||||
openMode: _openMode(creative.openMode),
|
||||
entryPromoEnabled: creative.entryPromoEnabled,
|
||||
entryBadgeText: creative.entryBadgeText.trim(),
|
||||
entryTipTitle: creative.entryTipTitle.trim(),
|
||||
entryTipBody: creative.entryTipBody.trim(),
|
||||
quoteCtaEnabled: promotion?.quoteCtaEnabled ?? false,
|
||||
allowedHosts: <String>{Uri.parse(url).host.toLowerCase()},
|
||||
);
|
||||
}
|
||||
|
||||
String _nonEmpty(String value, String fallback) =>
|
||||
value.trim().isEmpty ? fallback : value.trim();
|
||||
|
||||
String? _validUrl(String raw) {
|
||||
final uri = Uri.tryParse(raw.trim());
|
||||
return MallTabConfig._isAllowed(uri) ? uri.toString() : null;
|
||||
}
|
||||
|
||||
String _openMode(int value) => switch (value) {
|
||||
1 => 'native_app',
|
||||
2 => 'in_app',
|
||||
String _openMode(pb.AdOpenMode value) => switch (value) {
|
||||
pb.AdOpenMode.AD_OPEN_MODE_NATIVE_APP => 'native_app',
|
||||
pb.AdOpenMode.AD_OPEN_MODE_IN_APP => 'in_app',
|
||||
_ => 'external',
|
||||
};
|
||||
|
||||
/// Parses the protobuf reply returned by `/api/v1/ads?placement=mall_tab`.
|
||||
MallTabConfig parseMallTabConfigResponse(List<int> bytes) {
|
||||
final response = _ProtoMessage(bytes);
|
||||
final reply = response.message(2);
|
||||
final trackedUrl = reply.string(14);
|
||||
final h5Url = reply.string(9);
|
||||
final url = _validUrl(trackedUrl) ?? _validUrl(h5Url);
|
||||
if (response.integer(1) == 0 || url == null) {
|
||||
return const MallTabConfig.disabled();
|
||||
}
|
||||
return MallTabConfig(
|
||||
enabled: true,
|
||||
url: url,
|
||||
title: _nonEmpty(reply.string(4), '商城'),
|
||||
adId: reply.string(1),
|
||||
trackingId: reply.string(10),
|
||||
openMode: _openMode(reply.integer(15)),
|
||||
entryPromoEnabled: reply.integer(16) != 0,
|
||||
entryBadgeText: reply.string(17).trim(),
|
||||
entryTipTitle: reply.string(18).trim(),
|
||||
entryTipBody: reply.string(19).trim(),
|
||||
);
|
||||
}
|
||||
|
||||
class _ProtoMessage {
|
||||
_ProtoMessage(this._bytes);
|
||||
final List<int> _bytes;
|
||||
|
||||
int integer(int wanted) =>
|
||||
_fields()[wanted] is int ? _fields()[wanted]! as int : 0;
|
||||
String string(int wanted) => _fields()[wanted] is List<int>
|
||||
? utf8.decode(_fields()[wanted]! as List<int>, allowMalformed: true)
|
||||
: '';
|
||||
_ProtoMessage message(int wanted) => _ProtoMessage(
|
||||
_fields()[wanted] is List<int> ? _fields()[wanted]! as List<int> : const [],
|
||||
);
|
||||
|
||||
Map<int, Object> _fields() {
|
||||
final values = <int, Object>{};
|
||||
var index = 0;
|
||||
int readVarint() {
|
||||
var value = 0;
|
||||
var shift = 0;
|
||||
while (index < _bytes.length && shift < 64) {
|
||||
final byte = _bytes[index++];
|
||||
value |= (byte & 127) << shift;
|
||||
if (byte < 128) return value;
|
||||
shift += 7;
|
||||
}
|
||||
throw const FormatException('Invalid protobuf');
|
||||
}
|
||||
|
||||
while (index < _bytes.length) {
|
||||
final key = readVarint();
|
||||
final tag = key >> 3;
|
||||
switch (key & 7) {
|
||||
case 0:
|
||||
values[tag] = readVarint();
|
||||
case 2:
|
||||
final length = readVarint();
|
||||
final end = index + length;
|
||||
if (end > _bytes.length) {
|
||||
throw const FormatException('Invalid protobuf');
|
||||
}
|
||||
values[tag] = _bytes.sublist(index, end);
|
||||
index = end;
|
||||
default:
|
||||
throw const FormatException('Unsupported protobuf');
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
}
|
||||
|
||||
class _ProtoWriter {
|
||||
final _bytes = BytesBuilder(copy: false);
|
||||
_ProtoWriter string(int tag, String value) {
|
||||
final data = value.codeUnits;
|
||||
_varint((tag << 3) | 2);
|
||||
_varint(data.length);
|
||||
_bytes.add(data);
|
||||
return this;
|
||||
}
|
||||
|
||||
_ProtoWriter integer(int tag, int value) {
|
||||
_varint(tag << 3);
|
||||
_varint(value);
|
||||
return this;
|
||||
}
|
||||
|
||||
Uint8List toBytes() => _bytes.toBytes();
|
||||
void _varint(int value) {
|
||||
while (value > 127) {
|
||||
_bytes.addByte((value & 127) | 128);
|
||||
value >>= 7;
|
||||
}
|
||||
_bytes.addByte(value);
|
||||
String? _firstAllowedUrl(String trackedUrl, String h5Url) {
|
||||
for (final raw in <String>[trackedUrl, h5Url]) {
|
||||
final uri = Uri.tryParse(raw.trim());
|
||||
if (MallTabConfig._isAllowed(uri)) return uri.toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -19,6 +19,8 @@ class ProfilePage extends ConsumerWidget {
|
||||
final auth = ref.watch(authProvider);
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final loggedIn = auth.valueOrNull is LoggedInAuthState;
|
||||
final authLoading = auth.isLoading;
|
||||
final authFailed = auth.hasError;
|
||||
final currentUser = auth.valueOrNull is LoggedInAuthState
|
||||
? (auth.valueOrNull as LoggedInAuthState).user
|
||||
: null;
|
||||
@@ -100,8 +102,22 @@ class ProfilePage extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
FilledButton.tonal(
|
||||
onPressed: () => LoginLauncher.start(context, ref),
|
||||
child: Text(loggedIn ? '切换账号' : '去登录'),
|
||||
onPressed: authLoading
|
||||
? null
|
||||
: authFailed
|
||||
? () => ref
|
||||
.read(authProvider.notifier)
|
||||
.retryBootstrap()
|
||||
: () => LoginLauncher.start(context, ref),
|
||||
child: Text(
|
||||
authLoading
|
||||
? '正在恢复账号…'
|
||||
: authFailed
|
||||
? '账号恢复失败,点击重试'
|
||||
: loggedIn
|
||||
? '切换账号'
|
||||
: '去登录',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
OutlinedButton(
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import '../../../common/config/app_h5_urls.dart';
|
||||
import '../../../common/push/notification_permission.dart';
|
||||
@@ -32,6 +33,7 @@ class SettingsPage extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final themeMode = ref.watch(themeModeControllerProvider);
|
||||
final profileSettings = ref.watch(profileSettingsControllerProvider);
|
||||
final authAsync = ref.watch(authProvider);
|
||||
@@ -198,16 +200,28 @@ class SettingsPage extends HookConsumerWidget {
|
||||
_NavRow(
|
||||
icon: FlutterRemix.file_list_3_line,
|
||||
label: '用户协议',
|
||||
onTap: () => context.push(
|
||||
'${AppRoutes.web}?url=${Uri.encodeComponent(AppH5UrlsHelper.withCacheBuster(AppH5Urls.userProtocolUrl))}',
|
||||
onTap: () => launchUrlString(
|
||||
AppH5UrlsHelper.withCacheBuster(
|
||||
AppH5UrlsHelper.buildUrlWithNight(
|
||||
AppH5Urls.userProtocolUrl,
|
||||
isDark,
|
||||
),
|
||||
),
|
||||
mode: LaunchMode.inAppBrowserView,
|
||||
),
|
||||
),
|
||||
Divider(height: 1, thickness: 1, color: cs.border),
|
||||
_NavRow(
|
||||
icon: FlutterRemix.shield_check_line,
|
||||
label: '隐私政策',
|
||||
onTap: () => context.push(
|
||||
'${AppRoutes.web}?url=${Uri.encodeComponent(AppH5UrlsHelper.withCacheBuster(AppH5Urls.privacyUrl))}',
|
||||
onTap: () => launchUrlString(
|
||||
AppH5UrlsHelper.withCacheBuster(
|
||||
AppH5UrlsHelper.buildUrlWithNight(
|
||||
AppH5Urls.privacyUrl,
|
||||
isDark,
|
||||
),
|
||||
),
|
||||
mode: LaunchMode.inAppBrowserView,
|
||||
),
|
||||
),
|
||||
Divider(height: 1, thickness: 1, color: cs.border),
|
||||
|
||||
@@ -571,13 +571,13 @@ class _MallWebHostState extends ConsumerState<MallWebHost>
|
||||
final mallAuth = logged ? await _issueMallAuthToken() : null;
|
||||
if (!mounted) return;
|
||||
final arguments = buildEnterWebMallBridgeArguments(
|
||||
config: ref.read(mallTabConfigProvider),
|
||||
currentUri: uri!,
|
||||
logged: logged,
|
||||
isDark: Theme.of(context).brightness == Brightness.dark,
|
||||
authToken: mallAuth?.authToken,
|
||||
scene: mallAuth?.scene,
|
||||
sourceApp: mallAuth?.sourceApp,
|
||||
attribution: ref.read(mallClickAttributionProvider),
|
||||
);
|
||||
_callH5Handler(JsEvent('enterWebMall', [arguments]));
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
import '../application/web_navigation_policy.dart';
|
||||
|
||||
/// Shared HTTPS WebView host. Mall-specific residency and attribution are
|
||||
/// layered above this page so ordinary H5 links never receive mall state.
|
||||
class AppWebPage extends StatefulWidget {
|
||||
@@ -10,12 +13,10 @@ class AppWebPage extends StatefulWidget {
|
||||
super.key,
|
||||
required this.initialUri,
|
||||
this.hideTitle = false,
|
||||
this.allowedHosts = const <String>{},
|
||||
});
|
||||
|
||||
final Uri initialUri;
|
||||
final bool hideTitle;
|
||||
final Set<String> allowedHosts;
|
||||
|
||||
@override
|
||||
State<AppWebPage> createState() => _AppWebPageState();
|
||||
@@ -60,23 +61,19 @@ class _AppWebPageState extends State<AppWebPage> {
|
||||
|
||||
NavigationDecision _onNavigationRequest(NavigationRequest request) {
|
||||
final uri = Uri.tryParse(request.url);
|
||||
if (uri == null || !_isHttp(uri)) return NavigationDecision.prevent;
|
||||
if (_allowsInApp(uri)) return NavigationDecision.navigate;
|
||||
launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
return NavigationDecision.prevent;
|
||||
if (uri != null && shouldOpenWebUrlExternally(request.url)) {
|
||||
launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
if (uri == null || !_isAllowedWebUri(uri)) {
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
// Align with jinzhi-flutter-app: a normal HTTPS navigation stays in this
|
||||
// WebView even when its host changes. H5 opts into an external handoff
|
||||
// through `out`, which is handled above.
|
||||
return NavigationDecision.navigate;
|
||||
}
|
||||
|
||||
bool _allowsInApp(Uri uri) {
|
||||
final host = uri.host.toLowerCase();
|
||||
if (host == widget.initialUri.host.toLowerCase()) return true;
|
||||
return widget.allowedHosts.any(
|
||||
(allowed) => host == allowed || host.endsWith('.$allowed'),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isHttp(Uri uri) =>
|
||||
uri.hasAuthority && (uri.scheme == 'https' || uri.scheme == 'http');
|
||||
|
||||
Future<bool> _onPop() async {
|
||||
if (await _controller.canGoBack()) {
|
||||
await _controller.goBack();
|
||||
@@ -118,6 +115,16 @@ class _AppWebPageState extends State<AppWebPage> {
|
||||
|
||||
Uri? parseSafeWebUri(String? raw) {
|
||||
final uri = Uri.tryParse(raw?.trim() ?? '');
|
||||
if (uri == null || !uri.hasAuthority) return null;
|
||||
return uri.scheme == 'https' || uri.scheme == 'http' ? uri : null;
|
||||
return uri != null && _isAllowedWebUri(uri) ? uri : null;
|
||||
}
|
||||
|
||||
bool _isAllowedWebUri(Uri uri) {
|
||||
if (!uri.hasAuthority || uri.host.isEmpty) return false;
|
||||
if (uri.scheme.toLowerCase() == 'https') return true;
|
||||
if (kReleaseMode || uri.scheme.toLowerCase() != 'http') return false;
|
||||
return const {
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'::1',
|
||||
}.contains(uri.host.toLowerCase());
|
||||
}
|
||||
Reference in new issue
Block a user