fix:参考金值,研听,研值工程通用通用工程公共能力,重点对比网络请求、登录认证、状态管理、本地存储、WebView、推送、版本更新、一键登录、主题/UI 等模块,确定后续统一整改方案
This commit is contained in:
1 parent
6997a65c96
commit
d364c6d908
46 files changed
+2032
-367
No files matched your search
@@ -1,7 +1,9 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:app_links/app_links.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
@@ -15,6 +17,10 @@ import '../common/theme/theme_controller.dart';
|
||||
import '../features/ads/application/ad_config_foreground_refresher.dart';
|
||||
import '../features/settings/application/update_foreground_service.dart';
|
||||
import '../features/web/application/mall_web_host_controller.dart';
|
||||
import '../features/auth/application/auth_controller.dart';
|
||||
import '../features/auth/application/auth_session_coordinator.dart';
|
||||
import '../features/auth/data/auth_models.dart';
|
||||
import '../features/assets/application/asset_portfolio_controller.dart';
|
||||
import '../common/router/deep_link_service.dart';
|
||||
import '../common/services/device_header_service.dart';
|
||||
|
||||
@@ -28,14 +34,43 @@ class JinzhiApp extends ConsumerStatefulWidget {
|
||||
class _JinzhiAppState extends ConsumerState<JinzhiApp>
|
||||
with WidgetsBindingObserver {
|
||||
late final DevicePushRegistrar _pushRegistrar;
|
||||
ProviderSubscription<AsyncValue<AuthState>>? _authSubscription;
|
||||
StreamSubscription<Uri>? _appLinkSubscription;
|
||||
late final SessionEndListener _sessionEndListener;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_pushRegistrar = DevicePushRegistrar(ref.read)..start();
|
||||
_sessionEndListener = (reason) {
|
||||
if (reason == SessionEndReason.accountDeleted) {
|
||||
ref.read(assetPortfolioControllerProvider.notifier).clearHoldings();
|
||||
}
|
||||
};
|
||||
ref.read(authSessionCoordinatorProvider).addListener(_sessionEndListener);
|
||||
_authSubscription = ref.listenManual<AsyncValue<AuthState>>(authProvider, (
|
||||
previous,
|
||||
next,
|
||||
) {
|
||||
final oldUser = previous?.valueOrNull is LoggedInAuthState
|
||||
? (previous!.valueOrNull! as LoggedInAuthState).user.id
|
||||
: null;
|
||||
final newUser = next.valueOrNull is LoggedInAuthState
|
||||
? (next.valueOrNull! as LoggedInAuthState).user.id
|
||||
: null;
|
||||
if (oldUser != newUser) _pushRegistrar.forceRegisterCurrentToken();
|
||||
DeepLinkService.notifyAuthChanged();
|
||||
});
|
||||
UmengPushService.configureNotificationOpen(DeepLinkService.handle);
|
||||
unawaited(_startAppLinks());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
DeepLinkService.attachRouter((location) {
|
||||
final context = rootNavigatorKey.currentContext;
|
||||
if (context == null) return false;
|
||||
context.go(location);
|
||||
return true;
|
||||
}, authGuard: (_) => !ref.read(authProvider).isLoading);
|
||||
// 首次 iOS 启动在欢迎页点击进入后才注册 APNs,确保系统弹窗不被启动页
|
||||
// 或其他 Overlay 吞掉;后续启动和 Android 保持原有自动初始化。
|
||||
if (!DeviceHeaderService.to.iosStartupRequired) {
|
||||
@@ -44,9 +79,29 @@ class _JinzhiAppState extends ConsumerState<JinzhiApp>
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _startAppLinks() async {
|
||||
try {
|
||||
final appLinks = AppLinks();
|
||||
final initial = await appLinks.getInitialLink();
|
||||
if (initial != null) DeepLinkService.handleUri(initial);
|
||||
_appLinkSubscription = appLinks.uriLinkStream.listen(
|
||||
DeepLinkService.handleUri,
|
||||
onError: (_) {},
|
||||
);
|
||||
} catch (_) {
|
||||
// Desktop tests and unsupported platforms may not register the plugin.
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_pushRegistrar.stop());
|
||||
_authSubscription?.close();
|
||||
ref
|
||||
.read(authSessionCoordinatorProvider)
|
||||
.removeListener(_sessionEndListener);
|
||||
unawaited(_appLinkSubscription?.cancel());
|
||||
DeepLinkService.detachRouter();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:flutter/widgets.dart';
|
||||
import '../common/services/device_header_service.dart';
|
||||
import '../common/config/shorebird_service.dart';
|
||||
import '../common/config/umeng_config.dart';
|
||||
import '../common/storage/secure_identity_storage.dart';
|
||||
import '../common/storage/storage_service.dart';
|
||||
import '../common/widgets/easy_refresh_localization.dart';
|
||||
import 'app.dart';
|
||||
@@ -17,9 +16,6 @@ Future<Widget> bootstrap() async {
|
||||
configureEasyRefreshChineseLocalization();
|
||||
await UmengConfig.load();
|
||||
await StorageService.instance;
|
||||
await SecureIdentityStorage(
|
||||
StorageService.to.preferences,
|
||||
).restoreAccessToken();
|
||||
if (!kIsWeb) {
|
||||
if (Platform.isAndroid) {
|
||||
// Before consent this only initializes app version and channel. The
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api_exception.dart';
|
||||
|
||||
String? kratosReason(Object e) {
|
||||
if (e is! DioException) return null;
|
||||
final body = e.response?.data;
|
||||
if (body is Map && body['reason'] is String) return body['reason'] as String;
|
||||
if (e is DioException || e is ApiException || e is FormatException) {
|
||||
return ApiException.from(e).reason;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String kratosDisplayMessage(Object e, {required String fallback}) {
|
||||
if (kratosReason(e) == 'INTERNAL_ERROR') return fallback;
|
||||
if (e is DioException) {
|
||||
final body = e.response?.data;
|
||||
if (body is Map && body['message'] is String) {
|
||||
final m = body['message'] as String;
|
||||
if (m.isNotEmpty) return m;
|
||||
}
|
||||
final exception = ApiException.from(e);
|
||||
if (exception.serverMessage case final message? when message.isNotEmpty) {
|
||||
return message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
abstract final class ErrorMessageMapper {
|
||||
static String message(Object error, {required String fallback}) =>
|
||||
kratosDisplayMessage(error, fallback: fallback);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/pb/errors/errors.pb.dart' as kerrors;
|
||||
|
||||
enum ApiExceptionKind { transport, http, backend, decode, cancelled }
|
||||
|
||||
/// The single network failure shape exposed to application code.
|
||||
class ApiException implements Exception {
|
||||
const ApiException({
|
||||
required this.kind,
|
||||
this.httpStatus,
|
||||
this.backendCode,
|
||||
this.reason,
|
||||
this.serverMessage,
|
||||
this.requestOptions,
|
||||
this.cause,
|
||||
});
|
||||
|
||||
final ApiExceptionKind kind;
|
||||
final int? httpStatus;
|
||||
final int? backendCode;
|
||||
final String? reason;
|
||||
final String? serverMessage;
|
||||
final RequestOptions? requestOptions;
|
||||
final Object? cause;
|
||||
|
||||
bool get isUnauthorized =>
|
||||
httpStatus == 401 ||
|
||||
const {'INVALID_TOKEN', 'TOKEN_EXPIRED'}.contains(reason?.toUpperCase());
|
||||
|
||||
bool get isRetryable {
|
||||
if (kind == ApiExceptionKind.transport) return true;
|
||||
final status = httpStatus;
|
||||
return status == 408 || status == 429 || (status != null && status >= 500);
|
||||
}
|
||||
|
||||
int? get sessionGeneration =>
|
||||
requestOptions?.extra[authSessionGenerationExtra] as int?;
|
||||
|
||||
static ApiException from(Object error, {StackTrace? stackTrace}) {
|
||||
if (error is ApiException) return error;
|
||||
if (error is DioException) {
|
||||
if (error.error is ApiException) return error.error! as ApiException;
|
||||
return fromDio(error);
|
||||
}
|
||||
if (error is FormatException) {
|
||||
return ApiException(kind: ApiExceptionKind.decode, cause: error);
|
||||
}
|
||||
return ApiException(kind: ApiExceptionKind.decode, cause: error);
|
||||
}
|
||||
|
||||
static ApiException fromDio(DioException error) {
|
||||
final response = error.response;
|
||||
final status = response?.statusCode;
|
||||
final kratos = parseKratosError(response?.data);
|
||||
if (error.type == DioExceptionType.cancel) {
|
||||
return ApiException(
|
||||
kind: ApiExceptionKind.cancelled,
|
||||
httpStatus: status,
|
||||
requestOptions: error.requestOptions,
|
||||
cause: error,
|
||||
);
|
||||
}
|
||||
final kind = kratos != null
|
||||
? ApiExceptionKind.backend
|
||||
: status != null
|
||||
? ApiExceptionKind.http
|
||||
: ApiExceptionKind.transport;
|
||||
return ApiException(
|
||||
kind: kind,
|
||||
httpStatus: status,
|
||||
backendCode: kratos?.code,
|
||||
reason: _normalized(kratos?.reason),
|
||||
serverMessage: _normalized(kratos?.message),
|
||||
requestOptions: error.requestOptions,
|
||||
cause: error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ApiException($kind, status: $httpStatus, code: $backendCode, '
|
||||
'reason: $reason, message: $serverMessage)';
|
||||
}
|
||||
|
||||
const authSessionGenerationExtra = 'authSessionGeneration';
|
||||
|
||||
kerrors.Status? parseKratosError(Object? raw) {
|
||||
if (raw is List<int> && raw.isNotEmpty) {
|
||||
try {
|
||||
final status = kerrors.Status.fromBuffer(raw);
|
||||
if (status.code != 0 || status.reason.isNotEmpty) return status;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (raw is Map) {
|
||||
final code = raw['code'];
|
||||
final reason = '${raw['reason'] ?? ''}';
|
||||
final message = '${raw['message'] ?? ''}';
|
||||
if (code is num || reason.isNotEmpty || message.isNotEmpty) {
|
||||
return kerrors.Status(
|
||||
code: code is num ? code.toInt() : 0,
|
||||
reason: reason,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _normalized(String? value) {
|
||||
final result = value?.trim() ?? '';
|
||||
return result.isEmpty ? null : result;
|
||||
}
|
||||
|
||||
class ApiExceptionInterceptor extends Interceptor {
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
final apiException = ApiException.fromDio(err);
|
||||
handler.next(err.copyWith(error: apiException));
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,12 @@ import 'package:dio/dio.dart';
|
||||
|
||||
import '../services/device_header_service.dart';
|
||||
import '../storage/secure_identity_storage.dart';
|
||||
import '../storage/preferences_storage.dart';
|
||||
import '../storage/storage_service.dart';
|
||||
import 'api_exception.dart';
|
||||
|
||||
final _defaultIdentityStorage = SecureIdentityStorage(
|
||||
StorageService.to.preferences,
|
||||
PreferencesStorage(StorageService.to.preferences),
|
||||
);
|
||||
|
||||
Future<void> saveJwt(String token) => _defaultIdentityStorage.saveJwt(token);
|
||||
@@ -13,22 +15,51 @@ Future<void> clearJwt() => _defaultIdentityStorage.clearJwt();
|
||||
Future<String?> loadJwt() => _defaultIdentityStorage.readJwt();
|
||||
|
||||
class AuthInterceptor extends Interceptor {
|
||||
AuthInterceptor(this._identityStorage);
|
||||
AuthInterceptor(this._sessionStorage, {required Uri backendOrigin})
|
||||
: _backendOrigin = backendOrigin;
|
||||
|
||||
final SecureIdentityStorage _identityStorage;
|
||||
final AuthSessionStorage _sessionStorage;
|
||||
final Uri _backendOrigin;
|
||||
|
||||
@override
|
||||
Future<void> onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) async {
|
||||
if (!_sameOrigin(options.uri, _backendOrigin)) {
|
||||
_stripSensitiveHeaders(options.headers);
|
||||
handler.next(options);
|
||||
return;
|
||||
}
|
||||
await DeviceHeaderService.to.ensureInitialized();
|
||||
options.headers.addAll(DeviceHeaderService.to.commonHeaders());
|
||||
options.headers.addAll(DeviceHeaderService.to.platformIdentityHeaders());
|
||||
final jwt = await _identityStorage.readJwt();
|
||||
final jwt = _sessionStorage.accessToken ?? await _sessionStorage.restore();
|
||||
if (jwt != null && jwt.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $jwt';
|
||||
}
|
||||
options.extra[authSessionGenerationExtra] = _sessionStorage.generation;
|
||||
handler.next(options);
|
||||
}
|
||||
}
|
||||
|
||||
bool _sameOrigin(Uri request, Uri backend) =>
|
||||
request.scheme.toLowerCase() == backend.scheme.toLowerCase() &&
|
||||
request.host.toLowerCase() == backend.host.toLowerCase() &&
|
||||
request.port == backend.port;
|
||||
|
||||
void _stripSensitiveHeaders(Map<String, dynamic> headers) {
|
||||
const sensitive = {
|
||||
'authorization',
|
||||
'x-device-id',
|
||||
'user-devicetoken',
|
||||
'user-android-id',
|
||||
'user-android-oaid',
|
||||
'user-android-imei',
|
||||
'user-android-mac',
|
||||
'user-ios-idfv',
|
||||
'user-ios-idfa',
|
||||
'user-ios-paid',
|
||||
};
|
||||
headers.removeWhere((key, _) => sensitive.contains(key.toLowerCase()));
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
import '../storage/secure_identity_storage.dart';
|
||||
import '../../features/auth/application/auth_session_coordinator.dart';
|
||||
import 'api_exception.dart';
|
||||
import 'auth_interceptor.dart';
|
||||
import 'kratos_error_interceptor.dart';
|
||||
|
||||
@@ -26,7 +28,9 @@ AppConfig appConfig(Ref ref) => AppConfig.fromEnvironment();
|
||||
@Riverpod(keepAlive: true)
|
||||
Dio dio(Ref ref) {
|
||||
final config = ref.watch(appConfigProvider);
|
||||
final identityStorage = ref.watch(secureIdentityStorageProvider);
|
||||
final sessionStorage = ref.watch(authSessionStorageProvider);
|
||||
final coordinator = ref.watch(authSessionCoordinatorProvider);
|
||||
final backendOrigin = Uri.parse(config.apiBaseUrl);
|
||||
final d = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: config.apiBaseUrl,
|
||||
@@ -40,11 +44,57 @@ Dio dio(Ref ref) {
|
||||
},
|
||||
),
|
||||
);
|
||||
d.interceptors.add(AuthInterceptor(identityStorage));
|
||||
d.interceptors.add(
|
||||
AuthInterceptor(sessionStorage, backendOrigin: backendOrigin),
|
||||
);
|
||||
d.interceptors.add(KratosErrorInterceptor());
|
||||
d.interceptors.add(_SessionExpiryInterceptor(coordinator));
|
||||
if (!kReleaseMode) {
|
||||
d.interceptors.add(LogInterceptor(requestBody: false, responseBody: false));
|
||||
}
|
||||
d.interceptors.add(FancyDioInterceptor());
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
/// Explicit name for new code. [dioProvider] remains as a compatibility alias
|
||||
/// while existing feature clients are migrated incrementally.
|
||||
final backendDioProvider = dioProvider;
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Dio publicDio(Ref ref) => Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
),
|
||||
);
|
||||
|
||||
class _SessionExpiryInterceptor extends Interceptor {
|
||||
_SessionExpiryInterceptor(this._coordinator);
|
||||
|
||||
final AuthSessionCoordinator _coordinator;
|
||||
|
||||
@override
|
||||
Future<void> onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
final exception = ApiException.from(err);
|
||||
if (exception.isUnauthorized) {
|
||||
// Network code only emits the session event. Navigation and presentation
|
||||
// observe AuthState elsewhere.
|
||||
try {
|
||||
await _coordinator.expireIfNeeded(exception);
|
||||
} catch (error, stackTrace) {
|
||||
FlutterError.reportError(
|
||||
FlutterErrorDetails(
|
||||
exception: error,
|
||||
stack: stackTrace,
|
||||
library: 'jinzhi network',
|
||||
context: ErrorDescription('while expiring an unauthorized session'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,16 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/pb/errors/errors.pb.dart' as kerrors;
|
||||
import 'api_exception.dart';
|
||||
|
||||
class KratosErrorInterceptor extends Interceptor {
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
final response = err.response;
|
||||
final raw = response?.data;
|
||||
if (response != null && raw is List<int> && raw.isNotEmpty) {
|
||||
try {
|
||||
final status = kerrors.Status.fromBuffer(raw);
|
||||
if (status.code != 0 || status.reason.isNotEmpty) {
|
||||
response.data = <String, dynamic>{
|
||||
'code': status.code,
|
||||
'reason': status.reason,
|
||||
'message': status.message,
|
||||
'metadata': status.metadata,
|
||||
};
|
||||
}
|
||||
} catch (_) {
|
||||
// A non-Kratos error body keeps its original representation.
|
||||
}
|
||||
}
|
||||
if (err.error is ApiException) {
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
// Preserve response.data for diagnostics and retries. The parsed error is
|
||||
// carried as DioException.error and normalized by ApiException.from().
|
||||
handler.next(err.copyWith(error: ApiException.fromDio(err)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// Minimal reader retained only for endpoints that do not have an
|
||||
/// authoritative generated protobuf model yet.
|
||||
class ProtobufWireMessage {
|
||||
ProtobufWireMessage(this.bytes);
|
||||
|
||||
final List<int> bytes;
|
||||
Map<int, List<Object>>? _cache;
|
||||
|
||||
Map<int, List<Object>> get _fields => _cache ??= _read();
|
||||
int integer(int tag) => (_fields[tag]?.lastOrNull as int?) ?? 0;
|
||||
|
||||
String string(int tag) {
|
||||
final value = _fields[tag]?.lastOrNull;
|
||||
return value is List<int> ? utf8.decode(value, allowMalformed: true) : '';
|
||||
}
|
||||
|
||||
Map<int, List<Object>> _read() {
|
||||
final result = <int, List<Object>>{};
|
||||
var index = 0;
|
||||
|
||||
int varint() {
|
||||
var value = 0;
|
||||
var shift = 0;
|
||||
while (index < bytes.length) {
|
||||
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 = varint();
|
||||
final tag = key >> 3;
|
||||
final Object field;
|
||||
if ((key & 7) == 0) {
|
||||
field = varint();
|
||||
} else if ((key & 7) == 2) {
|
||||
final length = varint();
|
||||
final end = index + length;
|
||||
if (end > bytes.length) throw const FormatException('Invalid protobuf');
|
||||
field = bytes.sublist(index, end);
|
||||
index = end;
|
||||
} else {
|
||||
throw const FormatException('Unsupported protobuf');
|
||||
}
|
||||
(result[tag] ??= []).add(field);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
extension on List<Object> {
|
||||
Object? get lastOrNull => isEmpty ? null : last;
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import '../network/dio_provider.dart';
|
||||
import '../../models/pb/jinzhi/v1/push.pb.dart' as pb;
|
||||
import '../network/api_exception.dart';
|
||||
import 'notification_permission.dart';
|
||||
import 'push_api_client.dart';
|
||||
import 'umeng_push_service.dart';
|
||||
|
||||
class DevicePushRegistrar {
|
||||
@@ -15,81 +15,52 @@ class DevicePushRegistrar {
|
||||
StreamSubscription<String>? _subscription;
|
||||
String? _lastToken;
|
||||
bool? _lastPermission;
|
||||
int _attempt = 0;
|
||||
Future<void> _registrationQueue = Future.value();
|
||||
|
||||
void start() {
|
||||
_subscription ??= UmengPushService.onDeviceToken.listen(_register);
|
||||
_subscription ??= UmengPushService.onDeviceToken.listen(_enqueue);
|
||||
UmengPushService.emitCachedToken();
|
||||
}
|
||||
|
||||
Future<void> stop() async => _subscription?.cancel();
|
||||
Future<void> refresh() => UmengPushService.refreshDeviceToken();
|
||||
void forceRegisterCurrentToken() {
|
||||
// BLOCKED(server contract): guest registration is intentionally preserved.
|
||||
// Do not add a client-side unbind call until the backend confirms whether
|
||||
// guest registration reliably clears the previous user binding.
|
||||
_lastToken = null;
|
||||
_lastPermission = null;
|
||||
UmengPushService.emitCachedToken();
|
||||
}
|
||||
|
||||
void _enqueue(String token) {
|
||||
_registrationQueue = _registrationQueue
|
||||
.catchError((_) {})
|
||||
.then((_) => _register(token));
|
||||
}
|
||||
|
||||
Future<void> _register(String token) async {
|
||||
final permission = await NotificationPermission.isGranted();
|
||||
if (token == _lastToken && permission == _lastPermission) return;
|
||||
try {
|
||||
final package = await PackageInfo.fromPlatform();
|
||||
await _read(dioProvider).post<List<int>>(
|
||||
'/api/v1/push/devices',
|
||||
data: _PushRegisterRequest(
|
||||
token: token,
|
||||
platform: defaultTargetPlatform == TargetPlatform.iOS ? 1 : 2,
|
||||
version: package.version,
|
||||
permission: permission,
|
||||
).toBytes(),
|
||||
options: protobufOptions(),
|
||||
final request = pb.RegisterPushDeviceRequest(
|
||||
umengDeviceToken: token,
|
||||
platform: defaultTargetPlatform == TargetPlatform.iOS
|
||||
? pb.Platform.PLATFORM_IOS
|
||||
: pb.Platform.PLATFORM_ANDROID,
|
||||
appVersion: package.version,
|
||||
permissionGranted: permission,
|
||||
);
|
||||
for (var attempt = 0; attempt < 4; attempt++) {
|
||||
try {
|
||||
await _read(pushApiClientProvider).registerDevice(request);
|
||||
_lastToken = token;
|
||||
_lastPermission = permission;
|
||||
_attempt = 0;
|
||||
} catch (_) {
|
||||
if (_attempt++ >= 3) return;
|
||||
await Future<void>.delayed(Duration(seconds: 1 << (_attempt - 1)));
|
||||
await _register(token);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (attempt == 3 || !ApiException.from(error).isRetryable) return;
|
||||
await Future<void>.delayed(Duration(seconds: 1 << attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PushRegisterRequest {
|
||||
const _PushRegisterRequest({
|
||||
required this.token,
|
||||
required this.platform,
|
||||
required this.version,
|
||||
required this.permission,
|
||||
});
|
||||
final String token;
|
||||
final int platform;
|
||||
final String version;
|
||||
final bool permission;
|
||||
Uint8List toBytes() {
|
||||
final bytes = BytesBuilder(copy: false);
|
||||
void putInt(int value) {
|
||||
while (value > 0x7f) {
|
||||
bytes.addByte((value & 0x7f) | 0x80);
|
||||
value >>= 7;
|
||||
}
|
||||
bytes.addByte(value);
|
||||
}
|
||||
|
||||
void putString(int tag, String value) {
|
||||
final raw = value.codeUnits;
|
||||
putInt((tag << 3) | 2);
|
||||
putInt(raw.length);
|
||||
bytes.add(raw);
|
||||
}
|
||||
|
||||
putString(1, token);
|
||||
putInt(2 << 3);
|
||||
putInt(platform);
|
||||
putString(3, version);
|
||||
putInt(4 << 3);
|
||||
putInt(permission ? 1 : 0);
|
||||
return bytes.toBytes();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:dio/dio.dart';
|
||||
// ignore: unused_import — compute() is referenced by generated Retrofit code.
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:retrofit/retrofit.dart';
|
||||
|
||||
import '../../models/pb/jinzhi/v1/push.pb.dart';
|
||||
import '../network/dio_provider.dart';
|
||||
|
||||
part 'push_api_client.g.dart';
|
||||
|
||||
@RestApi()
|
||||
abstract class PushApiClient {
|
||||
factory PushApiClient(Dio dio, {String? baseUrl}) = _PushApiClient;
|
||||
|
||||
@POST('/api/v1/push/devices')
|
||||
Future<RegisterPushDeviceReply> registerDevice(
|
||||
@Body() RegisterPushDeviceRequest request,
|
||||
);
|
||||
}
|
||||
|
||||
final pushApiClientProvider = Provider<PushApiClient>(
|
||||
(ref) => PushApiClient(ref.watch(backendDioProvider)),
|
||||
);
|
||||
@@ -12,11 +12,12 @@ import '../storage/storage_keys.dart';
|
||||
import '../storage/storage_service.dart';
|
||||
|
||||
abstract final class UmengPushService {
|
||||
static const _maxPendingNotificationOpens = 20;
|
||||
static final _tokens = StreamController<String>.broadcast();
|
||||
static Future<void>? _starting;
|
||||
static bool _started = false;
|
||||
static void Function(String payload)? _onNotificationOpen;
|
||||
static String? _pendingNotificationOpen;
|
||||
static final List<String> _pendingNotificationOpens = [];
|
||||
|
||||
static Stream<String> get onDeviceToken => _tokens.stream;
|
||||
|
||||
@@ -24,10 +25,10 @@ abstract final class UmengPushService {
|
||||
void Function(String payload) callback,
|
||||
) {
|
||||
_onNotificationOpen = callback;
|
||||
final pending = _pendingNotificationOpen;
|
||||
if (pending != null) {
|
||||
_pendingNotificationOpen = null;
|
||||
callback(pending);
|
||||
final pending = List<String>.of(_pendingNotificationOpens);
|
||||
_pendingNotificationOpens.clear();
|
||||
for (final payload in pending) {
|
||||
callback(payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +57,10 @@ abstract final class UmengPushService {
|
||||
UmengPushSdk.setNotificationCallback((_) {}, (payload) {
|
||||
final callback = _onNotificationOpen;
|
||||
if (callback == null) {
|
||||
_pendingNotificationOpen = payload;
|
||||
if (_pendingNotificationOpens.length == _maxPendingNotificationOpens) {
|
||||
_pendingNotificationOpens.removeAt(0);
|
||||
}
|
||||
_pendingNotificationOpens.add(payload);
|
||||
} else {
|
||||
callback(payload);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,177 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
enum DeepLinkSource { push, appLink, universalLink, customScheme }
|
||||
|
||||
import 'app_router.dart';
|
||||
typedef DeepLinkNavigator = bool Function(String location);
|
||||
typedef DeepLinkAuthGuard = bool Function(String location);
|
||||
|
||||
/// 统一将 Scheme、Universal Link 和推送 payload 收敛为应用内路由。
|
||||
class DeepLinkEvent {
|
||||
const DeepLinkEvent({
|
||||
required this.raw,
|
||||
required this.source,
|
||||
required this.receivedAt,
|
||||
this.eventId,
|
||||
});
|
||||
|
||||
final String raw;
|
||||
final DeepLinkSource source;
|
||||
final DateTime receivedAt;
|
||||
final String? eventId;
|
||||
}
|
||||
|
||||
/// Source adapters feed one parser and a bounded FIFO. Navigation starts only
|
||||
/// after GoRouter has a context and the auth guard accepts the target.
|
||||
abstract final class DeepLinkService {
|
||||
static void handle(String raw) {
|
||||
final target = _route(raw);
|
||||
if (target != null) rootNavigatorKey.currentContext?.go(target);
|
||||
static const maxPending = 20;
|
||||
static const expiry = Duration(minutes: 15);
|
||||
static const _environmentHosts = String.fromEnvironment(
|
||||
'JINZHI_DEEP_LINK_HOSTS',
|
||||
);
|
||||
|
||||
static final List<DeepLinkEvent> _pending = [];
|
||||
static final Set<String> _pendingEventIds = {};
|
||||
static final Map<String, DateTime> _seenEventIds = {};
|
||||
static final Set<String> _configuredHttpsHosts = _normalizedHosts(
|
||||
_environmentHosts.split(','),
|
||||
);
|
||||
static Set<String> _trustedHttpsHosts = {..._configuredHttpsHosts};
|
||||
static DeepLinkNavigator? _navigator;
|
||||
static DeepLinkAuthGuard _authGuard = (_) => true;
|
||||
|
||||
static int get pendingCount => _pending.length;
|
||||
|
||||
static void handle(String raw) => enqueue(raw, source: DeepLinkSource.push);
|
||||
|
||||
static void handleUri(Uri uri) => enqueue(
|
||||
uri.toString(),
|
||||
source: uri.scheme.toLowerCase() == 'jinzhi'
|
||||
? DeepLinkSource.customScheme
|
||||
: DeepLinkSource.universalLink,
|
||||
);
|
||||
|
||||
static void enqueue(
|
||||
String raw, {
|
||||
required DeepLinkSource source,
|
||||
DateTime? receivedAt,
|
||||
}) {
|
||||
final normalized = raw.trim();
|
||||
if (normalized.isEmpty) return;
|
||||
final event = DeepLinkEvent(
|
||||
raw: normalized,
|
||||
source: source,
|
||||
receivedAt: receivedAt ?? DateTime.now(),
|
||||
eventId: _eventId(normalized),
|
||||
);
|
||||
_discardExpired(event.receivedAt);
|
||||
_seenEventIds.removeWhere(
|
||||
(_, seenAt) => event.receivedAt.difference(seenAt) > expiry,
|
||||
);
|
||||
if (event.eventId case final id? when _seenEventIds.containsKey(id)) return;
|
||||
if (_pending.length == maxPending) _removeAt(0);
|
||||
_pending.add(event);
|
||||
if (event.eventId case final id?) {
|
||||
_pendingEventIds.add(id);
|
||||
_seenEventIds[id] = event.receivedAt;
|
||||
}
|
||||
_drain();
|
||||
}
|
||||
|
||||
static void attachRouter(
|
||||
DeepLinkNavigator navigator, {
|
||||
DeepLinkAuthGuard? authGuard,
|
||||
}) {
|
||||
_navigator = navigator;
|
||||
_authGuard = authGuard ?? (_) => true;
|
||||
_drain();
|
||||
}
|
||||
|
||||
static void notifyAuthChanged() => _drain();
|
||||
|
||||
static void detachRouter() => _navigator = null;
|
||||
|
||||
/// Universal/App Link domains are supplied by the release environment. An
|
||||
/// empty allowlist intentionally disables HTTPS deep links until the native
|
||||
/// association domain is confirmed.
|
||||
static void configureTrustedHttpsHosts(Iterable<String> hosts) {
|
||||
_trustedHttpsHosts = _normalizedHosts(hosts);
|
||||
}
|
||||
|
||||
static void resetForTest() {
|
||||
_pending.clear();
|
||||
_pendingEventIds.clear();
|
||||
_seenEventIds.clear();
|
||||
_trustedHttpsHosts = {..._configuredHttpsHosts};
|
||||
_navigator = null;
|
||||
_authGuard = (_) => true;
|
||||
}
|
||||
|
||||
static void _drain() {
|
||||
final navigator = _navigator;
|
||||
if (navigator == null) return;
|
||||
_discardExpired(DateTime.now());
|
||||
while (_pending.isNotEmpty) {
|
||||
final event = _pending.first;
|
||||
final target = _route(event.raw);
|
||||
if (target == null) {
|
||||
_removeAt(0);
|
||||
continue;
|
||||
}
|
||||
if (!_authGuard(target) || !navigator(target)) return;
|
||||
_removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
static void _discardExpired(DateTime now) {
|
||||
while (_pending.isNotEmpty &&
|
||||
now.difference(_pending.first.receivedAt) > expiry) {
|
||||
_removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
static void _removeAt(int index) {
|
||||
final removed = _pending.removeAt(index);
|
||||
if (removed.eventId case final id?) _pendingEventIds.remove(id);
|
||||
}
|
||||
|
||||
static String? _eventId(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is Map) {
|
||||
final id = '${decoded['event_id'] ?? decoded['eventId'] ?? ''}'.trim();
|
||||
if (id.isNotEmpty) return id;
|
||||
}
|
||||
} catch (_) {}
|
||||
final uri = Uri.tryParse(raw);
|
||||
final id = uri?.queryParameters['event_id']?.trim();
|
||||
return id == null || id.isEmpty ? null : id;
|
||||
}
|
||||
|
||||
static String? _route(String raw) {
|
||||
var source = raw.trim();
|
||||
try {
|
||||
final json = jsonDecode(source);
|
||||
if (json is Map) {
|
||||
source = '${json['deep_link'] ?? json['url'] ?? json['link'] ?? ''}';
|
||||
final decoded = jsonDecode(source);
|
||||
if (decoded is Map) {
|
||||
source =
|
||||
'${decoded['deep_link'] ?? decoded['url'] ?? decoded['link'] ?? ''}';
|
||||
}
|
||||
} catch (_) {}
|
||||
final uri = Uri.tryParse(source);
|
||||
if (uri == null) return null;
|
||||
if (uri.scheme == 'jinzhi') {
|
||||
final path = uri.path.isEmpty ? '/assets' : uri.path;
|
||||
if (uri.scheme.toLowerCase() == 'jinzhi') {
|
||||
final path = uri.path.isNotEmpty
|
||||
? uri.path
|
||||
: uri.host.isNotEmpty
|
||||
? '/${uri.host}'
|
||||
: '/assets';
|
||||
return _allowed(path)
|
||||
? '$path${uri.hasQuery ? '?${uri.query}' : ''}'
|
||||
: null;
|
||||
}
|
||||
if ((uri.scheme == 'https' || uri.scheme == 'http') && _allowed(uri.path)) {
|
||||
if (uri.scheme.toLowerCase() == 'https' &&
|
||||
uri.port == 443 &&
|
||||
uri.userInfo.isEmpty &&
|
||||
_trustedHttpsHosts.contains(uri.host.toLowerCase()) &&
|
||||
_allowed(uri.path)) {
|
||||
return '${uri.path}${uri.hasQuery ? '?${uri.query}' : ''}';
|
||||
}
|
||||
return null;
|
||||
@@ -40,4 +184,9 @@ abstract final class DeepLinkService {
|
||||
'/profile',
|
||||
'/settings',
|
||||
}.contains(path);
|
||||
|
||||
static Set<String> _normalizedHosts(Iterable<String> hosts) => {
|
||||
for (final host in hosts)
|
||||
if (host.trim().isNotEmpty) host.trim().toLowerCase(),
|
||||
};
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import '../../features/web/presentation/mall_web_host.dart';
|
||||
import '../theme/app_icons.dart';
|
||||
import '../widgets/app_toast.dart';
|
||||
import 'app_router.dart';
|
||||
import '../storage/storage_service.dart';
|
||||
import '../storage/preferences_storage.dart';
|
||||
|
||||
class TabShell extends ConsumerStatefulWidget {
|
||||
const TabShell({
|
||||
@@ -65,7 +65,9 @@ class _TabShellState extends ConsumerState<TabShell>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mallEntryPromoStore = MallEntryPromoStore(StorageService.to.preferences);
|
||||
_mallEntryPromoStore = MallEntryPromoStore(
|
||||
ref.read(preferencesStorageProvider),
|
||||
);
|
||||
_mallEntryTipConsumed = _mallEntryPromoStore.hasOpenedMall;
|
||||
_mallHomeBadgeSeen = _mallEntryPromoStore.hasSeenHomeBadge;
|
||||
MallWebHostController.instance.state.addListener(_syncMallHost);
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import '../../features/web/application/mall_web_host_controller.dart';
|
||||
import '../storage/storage_service.dart';
|
||||
import '../storage/preferences_storage.dart';
|
||||
|
||||
/// Clears only disposable presentation data. Credentials and user preferences
|
||||
/// deliberately remain outside this service.
|
||||
@@ -34,7 +35,8 @@ class CacheClearService {
|
||||
/// in SharedPreferences and is removed here without touching user data.
|
||||
abstract final class SplashAdControllerPlaceholder {
|
||||
static Future<void> clear() async {
|
||||
await StorageService.to.preferences.remove('ads.splash.entry');
|
||||
await StorageService.to.preferences.remove('ads.splash.daily');
|
||||
final preferences = PreferencesStorage(StorageService.to.preferences);
|
||||
await preferences.remove('ads.splash.entry');
|
||||
await preferences.remove('ads.splash.daily');
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,14 @@ import 'package:android_oaid/android_oaid.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb;
|
||||
import 'package:flutter_paid/flutter_paid.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:platform_metadata/platform_metadata.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../storage/storage_keys.dart';
|
||||
import '../storage/storage_service.dart';
|
||||
import '../storage/preferences_storage.dart';
|
||||
import '../storage/secure_identity_storage.dart';
|
||||
import '../privacy/privacy_gate.dart';
|
||||
|
||||
/// 请求公共 + 平台身份 header 来源(完全对齐研值契约)。
|
||||
@@ -26,8 +27,6 @@ import '../privacy/privacy_gate.dart';
|
||||
class DeviceHeaderService {
|
||||
DeviceHeaderService._();
|
||||
static final DeviceHeaderService to = DeviceHeaderService._();
|
||||
static const _storage = FlutterSecureStorage();
|
||||
static const _kDeviceIdKey = 'device_id';
|
||||
// static const _platformMetadataChannel = MethodChannel('platform_metadata');
|
||||
|
||||
// 渠道:优先编译期 --dart-define=UMENG_CHANNEL=xxx;Android 其次读 manifest,
|
||||
@@ -48,6 +47,9 @@ class DeviceHeaderService {
|
||||
Future<void>? _baseInitialization;
|
||||
Future<void>? _identityInitialization;
|
||||
|
||||
SecureIdentityStorage get _identityStorage =>
|
||||
SecureIdentityStorage(PreferencesStorage(StorageService.to.preferences));
|
||||
|
||||
Future<void> ensureInitialized() async {
|
||||
_baseInitialization ??= _initializeBase();
|
||||
await _baseInitialization;
|
||||
@@ -89,14 +91,14 @@ class DeviceHeaderService {
|
||||
/// X-Device-Id:iOS 优先 IDFV(per-vendor 稳定),否则 UUIDv4;
|
||||
/// 首次生成后写入 secure_storage,后续复用。
|
||||
Future<String> _resolveDeviceId() async {
|
||||
final stored = await _storage.read(key: _kDeviceIdKey);
|
||||
final stored = await _identityStorage.readDeviceId();
|
||||
if (stored != null && stored.isNotEmpty) return stored;
|
||||
var id = '';
|
||||
if (!kIsWeb && Platform.isIOS) {
|
||||
id = await _resolveIdfv();
|
||||
}
|
||||
if (id.isEmpty) id = const Uuid().v4();
|
||||
await _storage.write(key: _kDeviceIdKey, value: id);
|
||||
await _identityStorage.saveDeviceId(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'storage_service.dart';
|
||||
|
||||
/// Typed boundary for non-sensitive preferences and display caches.
|
||||
///
|
||||
/// Feature code depends on this class instead of constructing or retaining a
|
||||
/// [SharedPreferences] instance.
|
||||
class PreferencesStorage {
|
||||
const PreferencesStorage(this._preferences);
|
||||
|
||||
final SharedPreferences _preferences;
|
||||
|
||||
bool getBool(String key, {bool defaultValue = false}) =>
|
||||
_preferences.getBool(key) ?? defaultValue;
|
||||
String? getString(String key) => _preferences.getString(key);
|
||||
bool containsKey(String key) => _preferences.containsKey(key);
|
||||
|
||||
Future<void> setBool(String key, bool value) =>
|
||||
_preferences.setBool(key, value);
|
||||
Future<void> setString(String key, String value) =>
|
||||
_preferences.setString(key, value);
|
||||
Future<void> remove(String key) => _preferences.remove(key);
|
||||
}
|
||||
|
||||
final preferencesStorageProvider = Provider<PreferencesStorage>(
|
||||
(ref) => PreferencesStorage(StorageService.to.preferences),
|
||||
);
|
||||
@@ -1,9 +1,8 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'preferences_storage.dart';
|
||||
import 'storage_keys.dart';
|
||||
import 'storage_service.dart';
|
||||
|
||||
abstract interface class SecureKeyValueStorage {
|
||||
Future<String?> read(String key);
|
||||
@@ -33,25 +32,33 @@ class SecureIdentityStorage {
|
||||
SecureKeyValueStorage secureStorage = const FlutterSecureKeyValueStorage(),
|
||||
}) : _storage = secureStorage;
|
||||
|
||||
static const _accessTokenKey = 'access_token';
|
||||
static const _legacySecureJwtKey = 'jwt_token';
|
||||
|
||||
final SharedPreferences _preferences;
|
||||
final PreferencesStorage _preferences;
|
||||
final SecureKeyValueStorage _storage;
|
||||
|
||||
Future<String?> restoreAccessToken() async {
|
||||
final current = _normalized(await _storage.read(_accessTokenKey));
|
||||
final legacyPreferences = _normalized(
|
||||
_preferences.getString(storageLegacyAccessToken),
|
||||
);
|
||||
final hasInstallSession = _preferences.getBool(storageAuthSessionActive);
|
||||
|
||||
// A plaintext token proves that this installation predates secure storage
|
||||
// migration. A Keychain token by itself does not: iOS may retain it after
|
||||
// uninstall while SharedPreferences (including this marker) is reset.
|
||||
if (!hasInstallSession && legacyPreferences == null) {
|
||||
await _deleteAllAuthCredentials();
|
||||
await _preferences.remove(storageAuthSessionActive);
|
||||
return null;
|
||||
}
|
||||
|
||||
final current = _normalized(await _storage.read(secureAccessToken));
|
||||
final legacySecure = current == null
|
||||
? _normalized(await _storage.read(_legacySecureJwtKey))
|
||||
? _normalized(await _storage.read(secureLegacyJwtToken))
|
||||
: null;
|
||||
final token = current ?? legacyPreferences ?? legacySecure;
|
||||
if (current == null && token != null) {
|
||||
await _storage.write(_accessTokenKey, token);
|
||||
await _storage.write(secureAccessToken, token);
|
||||
}
|
||||
await _storage.delete(_legacySecureJwtKey);
|
||||
await _storage.delete(secureLegacyJwtToken);
|
||||
await _preferences.remove(storageLegacyAccessToken);
|
||||
if (token == null) {
|
||||
await _preferences.remove(storageAuthSessionActive);
|
||||
@@ -62,10 +69,12 @@ class SecureIdentityStorage {
|
||||
}
|
||||
|
||||
Future<String?> readJwt() async {
|
||||
final token = _normalized(await _storage.read(_accessTokenKey));
|
||||
if (token == null || _preferences.containsKey(storageLegacyAccessToken)) {
|
||||
if (!_preferences.getBool(storageAuthSessionActive) ||
|
||||
_preferences.containsKey(storageLegacyAccessToken)) {
|
||||
return restoreAccessToken();
|
||||
}
|
||||
final token = _normalized(await _storage.read(secureAccessToken));
|
||||
if (token == null) await _preferences.remove(storageAuthSessionActive);
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -74,22 +83,100 @@ class SecureIdentityStorage {
|
||||
if (normalized == null) {
|
||||
throw ArgumentError.value(token, 'token', 'must not be empty');
|
||||
}
|
||||
await _storage.write(_accessTokenKey, normalized);
|
||||
await _storage.delete(_legacySecureJwtKey);
|
||||
await _storage.write(secureAccessToken, normalized);
|
||||
await _storage.delete(secureLegacyJwtToken);
|
||||
await _preferences.remove(storageLegacyAccessToken);
|
||||
await _preferences.setBool(storageAuthSessionActive, true);
|
||||
}
|
||||
|
||||
Future<void> clearJwt() async {
|
||||
await _storage.delete(_accessTokenKey);
|
||||
await _storage.delete(_legacySecureJwtKey);
|
||||
await _deleteAllAuthCredentials();
|
||||
await _preferences.remove(storageLegacyAccessToken);
|
||||
await _preferences.remove(storageAuthSessionActive);
|
||||
}
|
||||
|
||||
Future<void> clearSessionCredentials() async {
|
||||
await clearJwt();
|
||||
await _storage.delete(secureMallCredential);
|
||||
await _storage.delete(secureRefreshToken);
|
||||
}
|
||||
|
||||
Future<void> clearAccountCredentials() async {
|
||||
await clearSessionCredentials();
|
||||
await _storage.delete(secureBiometricCredential);
|
||||
}
|
||||
|
||||
Future<String?> readDeviceId() => _storage.read(secureDeviceId);
|
||||
|
||||
Future<void> saveDeviceId(String deviceId) =>
|
||||
_storage.write(secureDeviceId, deviceId);
|
||||
|
||||
Future<void> _deleteAllAuthCredentials() async {
|
||||
await _storage.delete(secureAccessToken);
|
||||
await _storage.delete(secureLegacyJwtToken);
|
||||
}
|
||||
}
|
||||
|
||||
final secureIdentityStorageProvider = Provider<SecureIdentityStorage>(
|
||||
(ref) => SecureIdentityStorage(StorageService.to.preferences),
|
||||
(ref) => SecureIdentityStorage(ref.watch(preferencesStorageProvider)),
|
||||
);
|
||||
|
||||
/// Process-memory session snapshot. Interceptors read this synchronously;
|
||||
/// secure storage is touched only when a session starts, restores, or ends.
|
||||
class AuthSessionStorage {
|
||||
AuthSessionStorage(this._identityStorage);
|
||||
|
||||
final SecureIdentityStorage _identityStorage;
|
||||
String? _accessToken;
|
||||
int _generation = 0;
|
||||
Future<String?>? _restoreFuture;
|
||||
Future<void> _operationTail = Future<void>.value();
|
||||
|
||||
String? get accessToken => _accessToken;
|
||||
int get generation => _generation;
|
||||
|
||||
Future<String?> restore() => _restoreFuture ??= _serialize(_restore);
|
||||
|
||||
Future<String?> _restore() async {
|
||||
_accessToken = await _identityStorage.restoreAccessToken();
|
||||
_generation++;
|
||||
return _accessToken;
|
||||
}
|
||||
|
||||
Future<void> save(String token) async {
|
||||
await _serialize(() async {
|
||||
await _identityStorage.saveJwt(token);
|
||||
_accessToken = token.trim();
|
||||
_generation++;
|
||||
_restoreFuture = Future<String?>.value(_accessToken);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> clear({bool accountDeleted = false}) async {
|
||||
await _serialize(() async {
|
||||
if (accountDeleted) {
|
||||
await _identityStorage.clearAccountCredentials();
|
||||
} else {
|
||||
await _identityStorage.clearSessionCredentials();
|
||||
}
|
||||
_accessToken = null;
|
||||
_generation++;
|
||||
_restoreFuture = Future<String?>.value();
|
||||
});
|
||||
}
|
||||
|
||||
Future<T> _serialize<T>(Future<T> Function() operation) {
|
||||
final result = _operationTail.then((_) => operation());
|
||||
_operationTail = result.then<void>(
|
||||
(_) {},
|
||||
onError: (Object _, StackTrace _) {},
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
final authSessionStorageProvider = Provider<AuthSessionStorage>(
|
||||
(ref) => AuthSessionStorage(ref.watch(secureIdentityStorageProvider)),
|
||||
);
|
||||
|
||||
String? _normalized(String? value) {
|
||||
|
||||
@@ -2,6 +2,7 @@ const storageThemeMode = 'theme_mode';
|
||||
const storagePrivacyAgreed = 'privacy_agreed'; // Android 首启隐私同意(设备采集门控)
|
||||
const storageIosStartupSeen = 'ios_startup_seen'; // iOS 首次启动页已展示
|
||||
const storageJinzhiHoldings = 'jinzhi_holdings';
|
||||
const storageJinzhiHoldingsDeleted = 'jinzhi_holdings_deleted';
|
||||
const storageJinzhiAmountHidden = 'jinzhi_amount_hidden';
|
||||
const storageJinzhiRecycleDiscounts = 'jinzhi_recycle_discounts';
|
||||
const storageJinzhiPurityPresets = 'jinzhi_purity_presets';
|
||||
@@ -18,6 +19,16 @@ const storageMallHomeBadgeSeen = 'jinzhi_mall_home_badge_seen';
|
||||
const storageLegacyAccessToken = 'access_token';
|
||||
const storageAuthSessionActive = 'auth_session_active';
|
||||
|
||||
// Account-scoped secure values. They are intentionally listed even before
|
||||
// every feature is implemented so account deletion has one complete cleanup
|
||||
// boundary. Access tokens must never be reused as biometric credentials.
|
||||
const secureAccessToken = 'access_token';
|
||||
const secureLegacyJwtToken = 'jwt_token';
|
||||
const secureDeviceId = 'device_id';
|
||||
const secureMallCredential = 'mall_credential';
|
||||
const secureRefreshToken = 'refresh_token';
|
||||
const secureBiometricCredential = 'biometric_credential';
|
||||
|
||||
// 设备/广告标识。由各自的 SDK/ATT 流程写入,DeviceHeaderService 读出注入
|
||||
// header;未写入则为空、注入时被过滤。
|
||||
const storageDeviceToken = 'device_token'; // 推送 token
|
||||
|
||||
@@ -16,7 +16,6 @@ class AppNetworkImage extends StatelessWidget {
|
||||
this.alignment = Alignment.center,
|
||||
this.borderRadius,
|
||||
this.cacheKey,
|
||||
this.httpHeaders,
|
||||
this.placeholder,
|
||||
this.errorWidget,
|
||||
this.semanticLabel,
|
||||
@@ -29,7 +28,6 @@ class AppNetworkImage extends StatelessWidget {
|
||||
final Alignment alignment;
|
||||
final BorderRadius? borderRadius;
|
||||
final String? cacheKey;
|
||||
final Map<String, String>? httpHeaders;
|
||||
final Widget? placeholder;
|
||||
final Widget? errorWidget;
|
||||
final String? semanticLabel;
|
||||
@@ -41,7 +39,6 @@ class AppNetworkImage extends StatelessWidget {
|
||||
: CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
cacheKey: cacheKey,
|
||||
httpHeaders: httpHeaders,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../network/api_error_message.dart';
|
||||
import 'app_state_card.dart';
|
||||
|
||||
/// Shared loading, failure and empty-state contract for feature pages.
|
||||
@@ -37,11 +38,11 @@ class AsyncStateView<T> extends StatelessWidget {
|
||||
return state.when(
|
||||
loading: () =>
|
||||
loading ?? const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => Center(
|
||||
error: (error, _) => Center(
|
||||
child: AppStateCard(
|
||||
icon: Icons.error_outline,
|
||||
title: errorTitle,
|
||||
subtitle: errorSubtitle,
|
||||
subtitle: ErrorMessageMapper.message(error, fallback: errorSubtitle),
|
||||
buttonLabel: onRetry == null ? null : '重试',
|
||||
onButtonPressed: onRetry,
|
||||
buttonStyle: AppStateButtonStyle.filled,
|
||||
@@ -60,3 +61,44 @@ class AsyncStateView<T> extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sliver counterpart for CustomScrollView pages without forcing one global
|
||||
/// card layout on successful feature content.
|
||||
class SliverAsyncState<T> extends StatelessWidget {
|
||||
const SliverAsyncState({
|
||||
super.key,
|
||||
required this.state,
|
||||
required this.data,
|
||||
this.loading,
|
||||
this.onRetry,
|
||||
this.errorSubtitle = '请检查网络后重试',
|
||||
});
|
||||
|
||||
final AsyncValue<T> state;
|
||||
final Widget Function(T value) data;
|
||||
final Widget? loading;
|
||||
final VoidCallback? onRetry;
|
||||
final String errorSubtitle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => state.when(
|
||||
data: data,
|
||||
loading: () => SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: loading ?? const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (error, _) => SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: AppStateCard(
|
||||
icon: Icons.error_outline,
|
||||
title: '加载失败',
|
||||
subtitle: ErrorMessageMapper.message(error, fallback: errorSubtitle),
|
||||
buttonLabel: onRetry == null ? null : '重试',
|
||||
onButtonPressed: onRetry,
|
||||
buttonStyle: AppStateButtonStyle.filled,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import 'dart:io' show File;
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/storage/storage_service.dart';
|
||||
import '../../../common/storage/preferences_storage.dart';
|
||||
import '../data/ads_api_client.dart';
|
||||
|
||||
class SplashAdEntry {
|
||||
@@ -33,12 +33,13 @@ class SplashAdEntry {
|
||||
}
|
||||
|
||||
class SplashAdController {
|
||||
SplashAdController(this._api);
|
||||
SplashAdController(this._api, this._preferences);
|
||||
final AdsApiClient _api;
|
||||
final PreferencesStorage _preferences;
|
||||
static const _entryKey = 'ads.splash.entry';
|
||||
static const _dailyKey = 'ads.splash.daily';
|
||||
Future<SplashAdEntry?> coldLaunchDecision() async {
|
||||
final raw = StorageService.to.preferences.getString(_entryKey);
|
||||
final raw = _preferences.getString(_entryKey);
|
||||
if (raw == null) return null;
|
||||
try {
|
||||
final entry = SplashAdEntry.fromJson(
|
||||
@@ -55,7 +56,7 @@ class SplashAdController {
|
||||
}
|
||||
|
||||
bool _underCap(SplashAdEntry entry) {
|
||||
final value = StorageService.to.preferences.getString(_dailyKey);
|
||||
final value = _preferences.getString(_dailyKey);
|
||||
final today = DateTime.now().toIso8601String().substring(0, 10);
|
||||
if (value == null) return true;
|
||||
final map = Map<String, dynamic>.from(jsonDecode(value) as Map);
|
||||
@@ -68,13 +69,13 @@ class SplashAdController {
|
||||
entry.assetPath != null && await File(entry.assetPath!).exists();
|
||||
Future<void> markShown(SplashAdEntry entry) async {
|
||||
final today = DateTime.now().toIso8601String().substring(0, 10);
|
||||
final raw = StorageService.to.preferences.getString(_dailyKey);
|
||||
final raw = _preferences.getString(_dailyKey);
|
||||
var count = 0;
|
||||
if (raw != null) {
|
||||
final m = Map<String, dynamic>.from(jsonDecode(raw) as Map);
|
||||
if (m['date'] == today) count = (m['count'] as num?)?.toInt() ?? 0;
|
||||
}
|
||||
await StorageService.to.preferences.setString(
|
||||
await _preferences.setString(
|
||||
_dailyKey,
|
||||
jsonEncode({'date': today, 'count': count + 1}),
|
||||
);
|
||||
@@ -92,7 +93,7 @@ class SplashAdController {
|
||||
result.ad!.imageUrl,
|
||||
);
|
||||
if (!await file.exists()) return;
|
||||
await StorageService.to.preferences.setString(
|
||||
await _preferences.setString(
|
||||
_entryKey,
|
||||
jsonEncode(
|
||||
SplashAdEntry(
|
||||
@@ -115,14 +116,17 @@ class SplashAdController {
|
||||
Future<void> skip(SplashAdEntry entry) => _report(entry.ad, 'ad_skip');
|
||||
Future<void> clearCache() async {
|
||||
await _clearEntry();
|
||||
await StorageService.to.preferences.remove(_dailyKey);
|
||||
await _preferences.remove(_dailyKey);
|
||||
}
|
||||
|
||||
Future<void> _clearEntry() async {
|
||||
await StorageService.to.preferences.remove(_entryKey);
|
||||
await _preferences.remove(_entryKey);
|
||||
}
|
||||
}
|
||||
|
||||
final splashAdControllerProvider = Provider(
|
||||
(ref) => SplashAdController(ref.watch(adsApiClientProvider)),
|
||||
(ref) => SplashAdController(
|
||||
ref.watch(adsApiClientProvider),
|
||||
ref.watch(preferencesStorageProvider),
|
||||
),
|
||||
);
|
||||
@@ -1,11 +1,17 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
// ignore: unused_import — compute() is referenced by generated Retrofit code.
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:retrofit/retrofit.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../../common/network/dio_provider.dart';
|
||||
import '../../../models/pb/jinzhi/v1/ads.pb.dart' as pb;
|
||||
import '../../../models/pb/jinzhi/v1/ads.pb.dart'
|
||||
show GetAdReply, ListAdsReply, ReportAdEventReply, ReportAdEventRequest;
|
||||
|
||||
part 'ads_api_client.g.dart';
|
||||
|
||||
const splashAdPlacement = 'splash';
|
||||
const homeBannerAdPlacement = 'home_banner';
|
||||
@@ -100,24 +106,26 @@ class AdCreative {
|
||||
? AdOpenMode.inApp
|
||||
: AdOpenMode.external,
|
||||
);
|
||||
factory AdCreative.fromProto(ProtoMessage fields) => AdCreative(
|
||||
adId: fields.string(1),
|
||||
placement: fields.string(2),
|
||||
targetApp: fields.string(3),
|
||||
title: fields.string(4),
|
||||
imageUrl: fields.string(6),
|
||||
mediaType: switch (fields.integer(7)) {
|
||||
1 => AdMediaType.image,
|
||||
2 => AdMediaType.gif,
|
||||
3 => AdMediaType.video,
|
||||
factory AdCreative.fromProto(pb.AdCreative creative) => AdCreative(
|
||||
adId: creative.adId,
|
||||
placement: creative.placement,
|
||||
targetApp: creative.targetApp,
|
||||
title: creative.title,
|
||||
imageUrl: creative.imageUrl,
|
||||
mediaType: switch (creative.mediaType) {
|
||||
pb.MediaType.MEDIA_TYPE_IMAGE => AdMediaType.image,
|
||||
pb.MediaType.MEDIA_TYPE_GIF => AdMediaType.gif,
|
||||
pb.MediaType.MEDIA_TYPE_VIDEO => AdMediaType.video,
|
||||
_ => AdMediaType.unsupported,
|
||||
},
|
||||
h5Url: fields.string(9),
|
||||
trackingId: fields.string(10),
|
||||
countdownSeconds: fields.integer(11),
|
||||
endAtMs: fields.integer(13),
|
||||
trackedUrl: fields.string(14),
|
||||
openMode: fields.integer(15) == 2 ? AdOpenMode.inApp : AdOpenMode.external,
|
||||
h5Url: creative.h5Url,
|
||||
trackingId: creative.trackingId,
|
||||
countdownSeconds: creative.countdownSeconds,
|
||||
endAtMs: creative.endAtMs.toInt(),
|
||||
trackedUrl: creative.trackedUrl,
|
||||
openMode: creative.openMode == pb.AdOpenMode.AD_OPEN_MODE_IN_APP
|
||||
? AdOpenMode.inApp
|
||||
: AdOpenMode.external,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,41 +144,49 @@ class FeaturedAdsState {
|
||||
bool get hasAds => enabled && ads.isNotEmpty;
|
||||
}
|
||||
|
||||
class AdsApiClient {
|
||||
AdsApiClient(this._dio);
|
||||
final Dio _dio;
|
||||
Future<GetAdResult> getAdResult(String placement) async {
|
||||
final response = await _dio.get<List<int>>(
|
||||
'/api/v1/ads',
|
||||
queryParameters: {'placement': placement},
|
||||
options: protobufOptions(),
|
||||
@RestApi()
|
||||
abstract class AdsBackendApiClient {
|
||||
factory AdsBackendApiClient(Dio dio, {String? baseUrl}) =
|
||||
_AdsBackendApiClient;
|
||||
|
||||
@GET('/api/v1/ads')
|
||||
Future<pb.GetAdReply> getAd({@Query('placement') required String placement});
|
||||
|
||||
@GET('/api/v1/ads/list')
|
||||
Future<pb.ListAdsReply> listAds({
|
||||
@Query('placement') required String placement,
|
||||
});
|
||||
|
||||
@POST('/api/v1/ads/events')
|
||||
Future<pb.ReportAdEventReply> reportAdEvent(
|
||||
@Body() pb.ReportAdEventRequest body,
|
||||
);
|
||||
final root = ProtoMessage(response.data ?? const []);
|
||||
final creative = root.message(2);
|
||||
final ad = AdCreative.fromProto(creative);
|
||||
}
|
||||
|
||||
class AdsApiClient {
|
||||
AdsApiClient(this._api);
|
||||
final AdsBackendApiClient _api;
|
||||
|
||||
Future<GetAdResult> getAdResult(String placement) async {
|
||||
final reply = await _api.getAd(placement: placement);
|
||||
final ad = reply.hasAd() ? AdCreative.fromProto(reply.ad) : null;
|
||||
return GetAdResult(
|
||||
root.integer(1) != 0,
|
||||
ad.isRenderable ? ad : null,
|
||||
root.integer(3),
|
||||
reply.enabled,
|
||||
ad?.isRenderable == true ? ad : null,
|
||||
reply.dailyCap,
|
||||
);
|
||||
}
|
||||
|
||||
Future<FeaturedAdsState> listAds(String placement) async {
|
||||
final response = await _dio.get<List<int>>(
|
||||
'/api/v1/ads/list',
|
||||
queryParameters: {'placement': placement},
|
||||
options: protobufOptions(),
|
||||
);
|
||||
final root = ProtoMessage(response.data ?? const []);
|
||||
final ads = root
|
||||
.messages(2)
|
||||
final reply = await _api.listAds(placement: placement);
|
||||
final ads = reply.ads
|
||||
.map(AdCreative.fromProto)
|
||||
.where((ad) => ad.isRenderable)
|
||||
.toList(growable: false);
|
||||
return FeaturedAdsState(
|
||||
root.integer(1) != 0,
|
||||
reply.enabled,
|
||||
ads,
|
||||
trackingId: root.string(3).isEmpty ? null : root.string(3),
|
||||
trackingId: reply.trackingId.isEmpty ? null : reply.trackingId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -179,23 +195,24 @@ class AdsApiClient {
|
||||
String event, {
|
||||
String? clickId,
|
||||
String? trackingId,
|
||||
}) => _dio.post<List<int>>(
|
||||
'/api/v1/ads/events',
|
||||
data: _Writer()
|
||||
.string(1, event)
|
||||
.string(2, ad.placement)
|
||||
.string(3, ad.adId)
|
||||
.string(4, trackingId ?? ad.trackingId)
|
||||
.string(5, clickId ?? '')
|
||||
.integer(6, DateTime.now().millisecondsSinceEpoch)
|
||||
.toBytes(),
|
||||
options: protobufOptions(),
|
||||
}) async {
|
||||
await _api.reportAdEvent(
|
||||
pb.ReportAdEventRequest(
|
||||
eventName: event,
|
||||
placement: ad.placement,
|
||||
adId: ad.adId,
|
||||
trackingId: trackingId ?? ad.trackingId,
|
||||
clickId: clickId ?? '',
|
||||
clientTsMs: Int64(DateTime.now().millisecondsSinceEpoch),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String newClickId() => const Uuid().v4();
|
||||
}
|
||||
|
||||
final adsApiClientProvider = Provider(
|
||||
(ref) => AdsApiClient(ref.watch(dioProvider)),
|
||||
(ref) => AdsApiClient(AdsBackendApiClient(ref.watch(backendDioProvider))),
|
||||
);
|
||||
final featuredAdsProvider = FutureProvider.autoDispose
|
||||
.family<FeaturedAdsState, String>(
|
||||
@@ -210,86 +227,3 @@ Uri? _http(String raw) {
|
||||
? uri
|
||||
: null;
|
||||
}
|
||||
|
||||
class ProtoMessage {
|
||||
ProtoMessage(this.bytes);
|
||||
final List<int> bytes;
|
||||
Map<int, List<Object>>? _cache;
|
||||
Map<int, List<Object>> get _fields => _cache ??= _read();
|
||||
int integer(int tag) => (_fields[tag]?.lastOrNull as int?) ?? 0;
|
||||
String string(int tag) {
|
||||
final value = _fields[tag]?.lastOrNull;
|
||||
return value is List<int> ? utf8.decode(value, allowMalformed: true) : '';
|
||||
}
|
||||
|
||||
ProtoMessage message(int tag) {
|
||||
final value = _fields[tag]?.lastOrNull;
|
||||
return ProtoMessage(value is List<int> ? value : const []);
|
||||
}
|
||||
|
||||
List<ProtoMessage> messages(int tag) => (_fields[tag] ?? const [])
|
||||
.whereType<List<int>>()
|
||||
.map(ProtoMessage.new)
|
||||
.toList();
|
||||
Map<int, List<Object>> _read() {
|
||||
final out = <int, List<Object>>{};
|
||||
var i = 0;
|
||||
int v() {
|
||||
var n = 0, s = 0;
|
||||
while (i < bytes.length) {
|
||||
final b = bytes[i++];
|
||||
n |= (b & 127) << s;
|
||||
if (b < 128) return n;
|
||||
s += 7;
|
||||
}
|
||||
throw const FormatException('Invalid protobuf');
|
||||
}
|
||||
|
||||
while (i < bytes.length) {
|
||||
final key = v(), tag = key >> 3;
|
||||
Object value;
|
||||
if ((key & 7) == 0) {
|
||||
value = v();
|
||||
} else if ((key & 7) == 2) {
|
||||
final l = v(), end = i + l;
|
||||
if (end > bytes.length) throw const FormatException('Invalid protobuf');
|
||||
value = bytes.sublist(i, end);
|
||||
i = end;
|
||||
} else {
|
||||
throw const FormatException('Unsupported protobuf');
|
||||
}
|
||||
(out[tag] ??= []).add(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
extension on List<Object> {
|
||||
Object? get lastOrNull => isEmpty ? null : last;
|
||||
}
|
||||
|
||||
class _Writer {
|
||||
final b = BytesBuilder(copy: false);
|
||||
_Writer string(int t, String value) {
|
||||
final d = value.codeUnits;
|
||||
v((t << 3) | 2);
|
||||
v(d.length);
|
||||
b.add(d);
|
||||
return this;
|
||||
}
|
||||
|
||||
_Writer integer(int t, int value) {
|
||||
v(t << 3);
|
||||
v(value);
|
||||
return this;
|
||||
}
|
||||
|
||||
Uint8List toBytes() => b.toBytes();
|
||||
void v(int n) {
|
||||
while (n > 127) {
|
||||
b.addByte((n & 127) | 128);
|
||||
n >>= 7;
|
||||
}
|
||||
b.addByte(n);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../../common/widgets/app_network_image.dart';
|
||||
import '../data/ads_api_client.dart';
|
||||
|
||||
/// 统一渲染广告图片/GIF/视频;开屏可优先使用已缓存的本地素材。
|
||||
@@ -86,12 +87,12 @@ class _AdMediaState extends State<AdMedia> {
|
||||
errorBuilder: (_, _, _) => _error(),
|
||||
);
|
||||
}
|
||||
return Image.network(
|
||||
widget.ad.imageUrl,
|
||||
return AppNetworkImage(
|
||||
imageUrl: widget.ad.imageUrl,
|
||||
fit: widget.fit,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
errorBuilder: (_, _, _) => _error(),
|
||||
errorWidget: _error(),
|
||||
);
|
||||
}
|
||||
final c = player;
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/domain/metal_type.dart';
|
||||
import '../../auth/application/auth_controller.dart';
|
||||
import '../../auth/data/auth_models.dart';
|
||||
import '../../market/data/mock_market_repository.dart';
|
||||
import '../data/asset_models.dart';
|
||||
import '../data/mock_asset_repository.dart';
|
||||
|
||||
final assetPortfolioControllerProvider =
|
||||
StateNotifierProvider<AssetPortfolioController, PortfolioSummary>((ref) {
|
||||
final holdings = ref.watch(mockAssetRepositoryProvider).loadHoldings();
|
||||
final repository = ref.watch(mockAssetRepositoryProvider);
|
||||
final authState = ref.watch(authProvider).valueOrNull;
|
||||
final accountScope = switch (authState) {
|
||||
LoggedInAuthState(:final user) => MockAssetRepository.accountScope(
|
||||
userId: user.id,
|
||||
phone: user.phone,
|
||||
),
|
||||
_ => MockAssetRepository.guestAccountScope,
|
||||
};
|
||||
final holdings = repository.loadHoldings(accountScope);
|
||||
final market = ref.watch(mockMarketRepositoryProvider);
|
||||
return AssetPortfolioController(
|
||||
holdings: holdings,
|
||||
repository: repository,
|
||||
accountScope: accountScope,
|
||||
spotPriceFor: (metal) => market.quoteFor(metal).spotPrice,
|
||||
changeFor: (metal) => market.quoteFor(metal).changeAmount,
|
||||
);
|
||||
@@ -19,10 +32,14 @@ final assetPortfolioControllerProvider =
|
||||
class AssetPortfolioController extends StateNotifier<PortfolioSummary> {
|
||||
AssetPortfolioController({
|
||||
required List<GoldAssetHolding> holdings,
|
||||
required MockAssetRepository repository,
|
||||
required String accountScope,
|
||||
required double Function(MetalType metal) spotPriceFor,
|
||||
required double Function(MetalType metal) changeFor,
|
||||
this.recycleDiscount = 0.97,
|
||||
}) : _holdings = holdings,
|
||||
_repository = repository,
|
||||
_accountScope = accountScope,
|
||||
_spotPriceFor = spotPriceFor,
|
||||
_changeFor = changeFor,
|
||||
super(
|
||||
@@ -35,12 +52,25 @@ class AssetPortfolioController extends StateNotifier<PortfolioSummary> {
|
||||
);
|
||||
|
||||
final List<GoldAssetHolding> _holdings;
|
||||
final MockAssetRepository _repository;
|
||||
final String _accountScope;
|
||||
final double Function(MetalType metal) _spotPriceFor;
|
||||
final double Function(MetalType metal) _changeFor;
|
||||
final double recycleDiscount;
|
||||
|
||||
void addHolding(GoldAssetHolding holding) {
|
||||
Future<void> addHolding(GoldAssetHolding holding) async {
|
||||
_holdings.add(holding);
|
||||
await _repository.saveHoldings(_accountScope, _holdings);
|
||||
state = _buildSummary(
|
||||
holdings: _holdings,
|
||||
spotPriceFor: _spotPriceFor,
|
||||
changeFor: _changeFor,
|
||||
recycleDiscount: recycleDiscount,
|
||||
);
|
||||
}
|
||||
|
||||
void clearHoldings() {
|
||||
_holdings.clear();
|
||||
state = _buildSummary(
|
||||
holdings: _holdings,
|
||||
spotPriceFor: _spotPriceFor,
|
||||
|
||||
@@ -51,6 +51,45 @@ class GoldAssetHolding {
|
||||
final HoldingStatus status;
|
||||
|
||||
double materialValue(double spotPrice) => weightGram * purity * spotPrice;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'metal': metal.name,
|
||||
'category': category.name,
|
||||
'purity': purity,
|
||||
'purityLabel': purityLabel,
|
||||
'weightGram': weightGram,
|
||||
'costAmount': costAmount,
|
||||
'purchaseDate': purchaseDate?.toIso8601String(),
|
||||
'channel': channel,
|
||||
'note': note,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
'status': status.name,
|
||||
};
|
||||
|
||||
factory GoldAssetHolding.fromJson(Map<String, Object?> json) {
|
||||
return GoldAssetHolding(
|
||||
id: json['id']! as String,
|
||||
name: json['name']! as String,
|
||||
metal: MetalType.values.byName(json['metal']! as String),
|
||||
category: AssetCategory.values.byName(json['category']! as String),
|
||||
purity: (json['purity']! as num).toDouble(),
|
||||
purityLabel: json['purityLabel']! as String,
|
||||
weightGram: (json['weightGram']! as num).toDouble(),
|
||||
costAmount: (json['costAmount'] as num?)?.toDouble(),
|
||||
purchaseDate: switch (json['purchaseDate']) {
|
||||
final String value when value.isNotEmpty => DateTime.parse(value),
|
||||
_ => null,
|
||||
},
|
||||
channel: json['channel'] as String?,
|
||||
note: json['note'] as String?,
|
||||
createdAt: DateTime.parse(json['createdAt']! as String),
|
||||
updatedAt: DateTime.parse(json['updatedAt']! as String),
|
||||
status: HoldingStatus.values.byName(json['status']! as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HoldingValuation {
|
||||
|
||||
@@ -1,14 +1,91 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/domain/metal_type.dart';
|
||||
import '../../../common/storage/preferences_storage.dart';
|
||||
import '../../../common/storage/storage_keys.dart';
|
||||
import 'asset_models.dart';
|
||||
|
||||
final mockAssetRepositoryProvider = Provider<MockAssetRepository>(
|
||||
(ref) => MockAssetRepository(),
|
||||
(ref) => MockAssetRepository(ref.watch(preferencesStorageProvider)),
|
||||
);
|
||||
|
||||
class MockAssetRepository {
|
||||
List<GoldAssetHolding> loadHoldings() {
|
||||
MockAssetRepository(this._preferences);
|
||||
|
||||
static const guestAccountScope = 'guest';
|
||||
|
||||
final PreferencesStorage _preferences;
|
||||
Future<void> _operationTail = Future<void>.value();
|
||||
|
||||
static String accountScope({String? userId, String? phone}) {
|
||||
final normalizedUserId = userId?.trim() ?? '';
|
||||
if (normalizedUserId.isNotEmpty) return 'id:$normalizedUserId';
|
||||
final normalizedPhone = phone?.trim() ?? '';
|
||||
if (normalizedPhone.isNotEmpty) return 'phone:$normalizedPhone';
|
||||
return guestAccountScope;
|
||||
}
|
||||
|
||||
static String holdingsStorageKey(String accountScope) =>
|
||||
'$storageJinzhiHoldings:${Uri.encodeComponent(accountScope)}';
|
||||
|
||||
static String deletedStorageKey(String accountScope) =>
|
||||
'$storageJinzhiHoldingsDeleted:${Uri.encodeComponent(accountScope)}';
|
||||
|
||||
List<GoldAssetHolding> loadHoldings(String accountScope) {
|
||||
if (_preferences.getBool(deletedStorageKey(accountScope))) return [];
|
||||
final encoded = _preferences.getString(holdingsStorageKey(accountScope));
|
||||
if (encoded == null || encoded.isEmpty) return _seedHoldings();
|
||||
try {
|
||||
final decoded = jsonDecode(encoded);
|
||||
if (decoded is! List) return _seedHoldings();
|
||||
return [
|
||||
for (final item in decoded)
|
||||
GoldAssetHolding.fromJson(Map<String, Object?>.from(item as Map)),
|
||||
];
|
||||
} catch (_) {
|
||||
return _seedHoldings();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveHoldings(
|
||||
String accountScope,
|
||||
List<GoldAssetHolding> holdings,
|
||||
) async {
|
||||
final encoded = jsonEncode([
|
||||
for (final holding in holdings) holding.toJson(),
|
||||
]);
|
||||
await _serialize(() async {
|
||||
await _preferences.setString(holdingsStorageKey(accountScope), encoded);
|
||||
await _preferences.remove(deletedStorageKey(accountScope));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deleteAccountHoldings(String accountScope) async {
|
||||
await _serialize(() async {
|
||||
final scopes = {accountScope, guestAccountScope};
|
||||
await Future.wait([
|
||||
for (final scope in scopes) ...[
|
||||
_preferences.remove(holdingsStorageKey(scope)),
|
||||
_preferences.setBool(deletedStorageKey(scope), true),
|
||||
],
|
||||
// Remove the pre-account-scoping key left by earlier builds.
|
||||
_preferences.remove(storageJinzhiHoldings),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
Future<T> _serialize<T>(Future<T> Function() operation) {
|
||||
final result = _operationTail.then((_) => operation());
|
||||
_operationTail = result.then<void>(
|
||||
(_) {},
|
||||
onError: (Object _, StackTrace _) {},
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
List<GoldAssetHolding> _seedHoldings() {
|
||||
final now = DateTime(2026, 6, 18, 16, 11);
|
||||
return [
|
||||
GoldAssetHolding(
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../../common/network/api_exception.dart';
|
||||
import '../../../common/network/dio_provider.dart';
|
||||
import '../../../common/storage/secure_identity_storage.dart';
|
||||
import '../../../common/storage/preferences_storage.dart';
|
||||
import '../../../common/storage/storage_keys.dart';
|
||||
import '../../../common/storage/storage_service.dart';
|
||||
import '../data/auth_api.dart';
|
||||
import '../data/auth_models.dart';
|
||||
import 'auth_session_coordinator.dart';
|
||||
|
||||
const _legacyMockLoggedInKey = 'jinzhi_mock_logged_in';
|
||||
|
||||
@@ -15,26 +15,27 @@ final authProvider =
|
||||
StateNotifierProvider<AuthController, AsyncValue<AuthState>>((ref) {
|
||||
return AuthController(
|
||||
ref.read(dioProvider),
|
||||
identityStorage: ref.read(secureIdentityStorageProvider),
|
||||
preferences: StorageService.to.preferences,
|
||||
preferences: ref.read(preferencesStorageProvider),
|
||||
coordinator: ref.read(authSessionCoordinatorProvider),
|
||||
);
|
||||
});
|
||||
|
||||
class AuthController extends StateNotifier<AsyncValue<AuthState>> {
|
||||
AuthController(
|
||||
this._dio, {
|
||||
required SecureIdentityStorage identityStorage,
|
||||
required SharedPreferences preferences,
|
||||
required PreferencesStorage preferences,
|
||||
required AuthSessionCoordinator coordinator,
|
||||
bool autoBootstrap = true,
|
||||
}) : _identityStorage = identityStorage,
|
||||
_preferences = preferences,
|
||||
}) : _preferences = preferences,
|
||||
_coordinator = coordinator,
|
||||
super(const AsyncLoading()) {
|
||||
_coordinator.addListener(_onSessionEnded);
|
||||
if (autoBootstrap) _bootstrap();
|
||||
}
|
||||
|
||||
final Dio _dio;
|
||||
final SecureIdentityStorage _identityStorage;
|
||||
final SharedPreferences _preferences;
|
||||
final PreferencesStorage _preferences;
|
||||
final AuthSessionCoordinator _coordinator;
|
||||
|
||||
AuthApi get _api => AuthApi(_dio);
|
||||
|
||||
@@ -42,28 +43,40 @@ class AuthController extends StateNotifier<AsyncValue<AuthState>> {
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
state = const AsyncLoading();
|
||||
final token = await _identityStorage.readJwt();
|
||||
final token = await _coordinator.restore();
|
||||
if (token == null || token.isEmpty) {
|
||||
state = const AsyncData(GuestAuthState());
|
||||
return;
|
||||
}
|
||||
final cached = _readCachedProfile();
|
||||
if (cached != null) {
|
||||
state = AsyncData(
|
||||
LoggedInAuthState(user: cached, token: token, isVerified: false),
|
||||
);
|
||||
await _persistProfile(cached);
|
||||
}
|
||||
await _verifyProfile(token, _coordinator.generation);
|
||||
}
|
||||
|
||||
Future<void> _verifyProfile(String token, int generation) async {
|
||||
try {
|
||||
final user = await _api.getProfile();
|
||||
if (generation != _coordinator.generation) return;
|
||||
await _persistProfile(user);
|
||||
state = AsyncData(LoggedInAuthState(user: user, token: token));
|
||||
state = AsyncData(
|
||||
LoggedInAuthState(user: user, token: token, isVerified: true),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
if (_isUnauthorized(error)) {
|
||||
await _clearLocalSession();
|
||||
state = const AsyncData(GuestAuthState());
|
||||
if (generation != _coordinator.generation) return;
|
||||
final exception = ApiException.from(error, stackTrace: stackTrace);
|
||||
if (await _coordinator.expireIfNeeded(exception)) {
|
||||
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);
|
||||
}
|
||||
// Any non-session error (transport, 403, 429, 5xx, decode) preserves a
|
||||
// stale cached session. With no profile there is nothing safe to render,
|
||||
// but the credential still remains available for retry.
|
||||
if (state.valueOrNull is LoggedInAuthState) return;
|
||||
state = AsyncError(exception, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +93,7 @@ class AuthController extends StateNotifier<AsyncValue<AuthState>> {
|
||||
}
|
||||
|
||||
Future<void> _persistLogin(AuthLoginResult reply) async {
|
||||
await _identityStorage.saveJwt(reply.accessToken);
|
||||
await _coordinator.startSession(reply.accessToken);
|
||||
await _persistProfile(reply.user);
|
||||
state = AsyncData(
|
||||
LoggedInAuthState(user: reply.user, token: reply.accessToken),
|
||||
@@ -88,8 +101,7 @@ class AuthController extends StateNotifier<AsyncValue<AuthState>> {
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _clearLocalSession();
|
||||
state = const AsyncData(GuestAuthState());
|
||||
await _coordinator.logout();
|
||||
}
|
||||
|
||||
Future<void> deleteAccount(String code) async {
|
||||
@@ -98,8 +110,7 @@ class AuthController extends StateNotifier<AsyncValue<AuthState>> {
|
||||
throw ArgumentError.value(code, 'code', 'must not be empty');
|
||||
}
|
||||
await _api.deleteAccount(normalized);
|
||||
await _clearLocalSession();
|
||||
state = const AsyncData(GuestAuthState());
|
||||
await _coordinator.accountDeleted();
|
||||
}
|
||||
|
||||
Future<void> _persistProfile(JinzhiUser user) async {
|
||||
@@ -122,22 +133,19 @@ class AuthController extends StateNotifier<AsyncValue<AuthState>> {
|
||||
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);
|
||||
void _onSessionEnded(SessionEndReason reason) {
|
||||
state = AsyncData(
|
||||
reason == SessionEndReason.expired
|
||||
? const SessionExpiredAuthState()
|
||||
: const GuestAuthState(),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
@override
|
||||
void dispose() {
|
||||
_coordinator.removeListener(_onSessionEnded);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/network/api_exception.dart';
|
||||
import '../../../common/storage/preferences_storage.dart';
|
||||
import '../../../common/storage/secure_identity_storage.dart';
|
||||
import '../../../common/storage/storage_keys.dart';
|
||||
import '../../assets/data/mock_asset_repository.dart';
|
||||
|
||||
enum SessionEndReason { logout, expired, accountDeleted }
|
||||
|
||||
typedef SessionEndListener = void Function(SessionEndReason reason);
|
||||
|
||||
/// Owns session invalidation without depending on Navigator, Router or UI.
|
||||
class AuthSessionCoordinator {
|
||||
AuthSessionCoordinator(
|
||||
this._sessionStorage,
|
||||
this._preferences,
|
||||
this._assetRepository,
|
||||
);
|
||||
|
||||
final AuthSessionStorage _sessionStorage;
|
||||
final PreferencesStorage _preferences;
|
||||
final MockAssetRepository _assetRepository;
|
||||
final Set<SessionEndListener> _listeners = {};
|
||||
Future<void>? _ending;
|
||||
SessionEndReason? _endingReason;
|
||||
|
||||
int get generation => _sessionStorage.generation;
|
||||
|
||||
void addListener(SessionEndListener listener) => _listeners.add(listener);
|
||||
void removeListener(SessionEndListener listener) =>
|
||||
_listeners.remove(listener);
|
||||
|
||||
Future<String?> restore() => _sessionStorage.restore();
|
||||
|
||||
Future<void> startSession(String token) async {
|
||||
final ending = _ending;
|
||||
if (ending != null) await ending;
|
||||
await _sessionStorage.save(token);
|
||||
}
|
||||
|
||||
Future<bool> expireIfNeeded(ApiException exception) async {
|
||||
if (!exception.isUnauthorized) return false;
|
||||
final requestGeneration = exception.sessionGeneration;
|
||||
if (requestGeneration != null && requestGeneration != generation) {
|
||||
return false;
|
||||
}
|
||||
await end(SessionEndReason.expired);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> logout() => end(SessionEndReason.logout);
|
||||
|
||||
Future<void> accountDeleted() => end(SessionEndReason.accountDeleted);
|
||||
|
||||
Future<void> end(SessionEndReason reason) {
|
||||
_endingReason = _strongerReason(_endingReason, reason);
|
||||
final active = _ending;
|
||||
if (active != null) return active;
|
||||
final operation = _drainEnd();
|
||||
late final Future<void> tracked;
|
||||
tracked = operation.whenComplete(() {
|
||||
if (identical(_ending, tracked)) {
|
||||
_ending = null;
|
||||
_endingReason = null;
|
||||
}
|
||||
});
|
||||
_ending = tracked;
|
||||
return tracked;
|
||||
}
|
||||
|
||||
Future<void> _drainEnd() async {
|
||||
final assetAccountScope = MockAssetRepository.accountScope(
|
||||
userId: _preferences.getString(storageUserId),
|
||||
phone: _preferences.getString(storageUserPhone),
|
||||
);
|
||||
SessionEndReason? completedReason;
|
||||
while (completedReason != _endingReason) {
|
||||
final reason = _endingReason!;
|
||||
await _sessionStorage.clear(
|
||||
accountDeleted: reason == SessionEndReason.accountDeleted,
|
||||
);
|
||||
await Future.wait([
|
||||
_preferences.remove(storageUserId),
|
||||
_preferences.remove(storageUserPhone),
|
||||
_preferences.remove(storageUserCreatedAt),
|
||||
_preferences.remove('jinzhi_mock_logged_in'),
|
||||
if (reason == SessionEndReason.accountDeleted)
|
||||
_assetRepository.deleteAccountHoldings(assetAccountScope),
|
||||
]);
|
||||
completedReason = reason;
|
||||
}
|
||||
final finalReason = _endingReason!;
|
||||
for (final listener in List<SessionEndListener>.of(_listeners)) {
|
||||
listener(finalReason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SessionEndReason _strongerReason(
|
||||
SessionEndReason? current,
|
||||
SessionEndReason next,
|
||||
) {
|
||||
if (current == null) return next;
|
||||
return _reasonPriority(next) > _reasonPriority(current) ? next : current;
|
||||
}
|
||||
|
||||
int _reasonPriority(SessionEndReason reason) => switch (reason) {
|
||||
SessionEndReason.expired => 0,
|
||||
SessionEndReason.logout => 1,
|
||||
SessionEndReason.accountDeleted => 2,
|
||||
};
|
||||
|
||||
final authSessionCoordinatorProvider = Provider<AuthSessionCoordinator>(
|
||||
(ref) => AuthSessionCoordinator(
|
||||
ref.watch(authSessionStorageProvider),
|
||||
ref.watch(preferencesStorageProvider),
|
||||
ref.watch(mockAssetRepositoryProvider),
|
||||
),
|
||||
);
|
||||
@@ -20,9 +20,18 @@ class GuestAuthState extends AuthState {
|
||||
const GuestAuthState();
|
||||
}
|
||||
|
||||
class SessionExpiredAuthState extends AuthState {
|
||||
const SessionExpiredAuthState();
|
||||
}
|
||||
|
||||
class LoggedInAuthState extends AuthState {
|
||||
const LoggedInAuthState({required this.user, required this.token});
|
||||
const LoggedInAuthState({
|
||||
required this.user,
|
||||
required this.token,
|
||||
this.isVerified = true,
|
||||
});
|
||||
|
||||
final JinzhiUser user;
|
||||
final String token;
|
||||
final bool isVerified;
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../../common/storage/preferences_storage.dart';
|
||||
import '../../../common/storage/storage_keys.dart';
|
||||
|
||||
class MallEntryPromoStore {
|
||||
const MallEntryPromoStore(this._preferences);
|
||||
|
||||
final SharedPreferences _preferences;
|
||||
final PreferencesStorage _preferences;
|
||||
|
||||
bool get hasOpenedMall =>
|
||||
_preferences.getBool(storageMallEntryPromoOpened) ?? false;
|
||||
bool get hasOpenedMall => _preferences.getBool(storageMallEntryPromoOpened);
|
||||
|
||||
bool get hasSeenHomeBadge =>
|
||||
_preferences.getBool(storageMallHomeBadgeSeen) ?? false;
|
||||
bool get hasSeenHomeBadge => _preferences.getBool(storageMallHomeBadgeSeen);
|
||||
|
||||
Future<void> markMallOpened() async {
|
||||
await _preferences.setBool(storageMallEntryPromoOpened, true);
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/network/dio_provider.dart';
|
||||
import '../../ads/data/ads_api_client.dart';
|
||||
import '../../../common/network/protobuf_wire_reader.dart';
|
||||
|
||||
class AppMallAuthToken {
|
||||
const AppMallAuthToken({
|
||||
@@ -33,7 +33,7 @@ class MallAuthApi {
|
||||
data: Uint8List(0),
|
||||
options: protobufOptions(),
|
||||
);
|
||||
final reply = ProtoMessage(response.data ?? const <int>[]);
|
||||
final reply = ProtobufWireMessage(response.data ?? const <int>[]);
|
||||
final token = reply.string(1).trim();
|
||||
final expiresIn = reply.integer(2);
|
||||
final sourceApp = reply.string(3).trim();
|
||||
|
||||
@@ -48,6 +48,8 @@ class UpgradeInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// BLOCKED(proto): this endpoint has no authoritative generated Upgrade proto.
|
||||
// Keep its existing wire contract until that source becomes available.
|
||||
class UpdateApiClient {
|
||||
UpdateApiClient(this._dio);
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -31,7 +33,12 @@ class _AppWebPageState extends State<AppWebPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = WebViewController()
|
||||
_controller =
|
||||
WebViewController(
|
||||
// Ordinary pages never receive camera/microphone or other protected
|
||||
// resources. MallWebHost owns its separate permission policy.
|
||||
onPermissionRequest: (request) => unawaited(request.deny()),
|
||||
)
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..addJavaScriptChannel(
|
||||
'JinzhiBridge',
|
||||
|
||||
@@ -59,6 +59,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.3"
|
||||
app_links:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: app_links
|
||||
sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.1"
|
||||
app_links_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: app_links_linux
|
||||
sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
app_links_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: app_links_platform_interface
|
||||
sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
app_links_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: app_links_web
|
||||
sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
app_tracking_transparency:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -664,6 +696,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
gtk:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: gtk
|
||||
sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
hooks_riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
+2
-1
@@ -2,7 +2,7 @@ name: jinzhi_flutter_app
|
||||
description: "金值 - 贵金属资产管家"
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.1.0+1
|
||||
version: 1.3.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.2
|
||||
@@ -18,6 +18,7 @@ dependencies:
|
||||
flutter_hooks: ^0.21.3+1
|
||||
# 路由
|
||||
go_router: ^16.2.4
|
||||
app_links: ^6.4.1
|
||||
# 网络 + 序列化
|
||||
dio: ^5.9.0
|
||||
retrofit: ">=4.6.0 <4.9.2"
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/network/api_exception.dart';
|
||||
import 'package:jinzhi_flutter_app/common/network/kratos_error_interceptor.dart';
|
||||
import 'package:jinzhi_flutter_app/models/pb/errors/errors.pb.dart' as errors;
|
||||
|
||||
void main() {
|
||||
test('classifies Kratos invalid token and preserves the raw body', () async {
|
||||
final body = errors.Status(
|
||||
code: 401,
|
||||
reason: 'INVALID_TOKEN',
|
||||
message: 'expired',
|
||||
).writeToBuffer();
|
||||
final dio = Dio()
|
||||
..httpClientAdapter = _ErrorAdapter(body, 401)
|
||||
..interceptors.add(KratosErrorInterceptor());
|
||||
|
||||
try {
|
||||
await dio.get<List<int>>(
|
||||
'/me',
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
fail('request should fail');
|
||||
} on DioException catch (error) {
|
||||
final exception = ApiException.from(error);
|
||||
expect(exception.kind, ApiExceptionKind.backend);
|
||||
expect(exception.reason, 'INVALID_TOKEN');
|
||||
expect(exception.isUnauthorized, isTrue);
|
||||
expect(error.response!.data, body);
|
||||
}
|
||||
});
|
||||
|
||||
test('classifies transport, retryable server errors, and cancellation', () {
|
||||
final request = RequestOptions(path: '/test');
|
||||
expect(
|
||||
ApiException.fromDio(DioException(requestOptions: request)).kind,
|
||||
ApiExceptionKind.transport,
|
||||
);
|
||||
expect(
|
||||
ApiException.fromDio(
|
||||
DioException(
|
||||
requestOptions: request,
|
||||
response: Response<void>(requestOptions: request, statusCode: 503),
|
||||
),
|
||||
).isRetryable,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ApiException.fromDio(
|
||||
DioException(requestOptions: request, type: DioExceptionType.cancel),
|
||||
).kind,
|
||||
ApiExceptionKind.cancelled,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class _ErrorAdapter implements HttpClientAdapter {
|
||||
_ErrorAdapter(this.body, this.status);
|
||||
|
||||
final List<int> body;
|
||||
final int status;
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async => ResponseBody.fromBytes(body, status);
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/network/auth_interceptor.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/secure_identity_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('external absolute URLs lose all backend identity headers', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final session = AuthSessionStorage(
|
||||
SecureIdentityStorage(
|
||||
PreferencesStorage(preferences),
|
||||
secureStorage: _MemorySecureStorage(),
|
||||
),
|
||||
);
|
||||
final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'))
|
||||
..interceptors.add(
|
||||
AuthInterceptor(
|
||||
session,
|
||||
backendOrigin: Uri.parse('https://api.example.com'),
|
||||
),
|
||||
)
|
||||
..httpClientAdapter = _CaptureAdapter();
|
||||
|
||||
final response = await dio.get<void>(
|
||||
'https://cdn.example.net/ad.png',
|
||||
options: Options(
|
||||
headers: {
|
||||
'Authorization': 'Bearer leaked',
|
||||
'X-Device-Id': 'device',
|
||||
'User-iOS-IDFA': 'idfa',
|
||||
'User-DeviceToken': 'push',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final headers = response.requestOptions.headers.keys
|
||||
.map((key) => key.toLowerCase())
|
||||
.toSet();
|
||||
expect(headers, isNot(contains('authorization')));
|
||||
expect(headers, isNot(contains('x-device-id')));
|
||||
expect(headers, isNot(contains('user-ios-idfa')));
|
||||
expect(headers, isNot(contains('user-devicetoken')));
|
||||
});
|
||||
}
|
||||
|
||||
class _MemorySecureStorage implements SecureKeyValueStorage {
|
||||
final Map<String, String> values = {};
|
||||
|
||||
@override
|
||||
Future<void> delete(String key) async => values.remove(key);
|
||||
@override
|
||||
Future<String?> read(String key) async => values[key];
|
||||
@override
|
||||
Future<void> write(String key, String value) async => values[key] = value;
|
||||
}
|
||||
|
||||
class _CaptureAdapter implements HttpClientAdapter {
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async => ResponseBody.fromString('', 204);
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/push/push_api_client.dart';
|
||||
import 'package:jinzhi_flutter_app/models/pb/jinzhi/v1/push.pb.dart' as pb;
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'Retrofit push client uses generated protobuf in both directions',
|
||||
() async {
|
||||
final adapter = _PushAdapter();
|
||||
final client = PushApiClient(
|
||||
Dio(BaseOptions(responseType: ResponseType.bytes))
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
|
||||
final reply = await client.registerDevice(
|
||||
pb.RegisterPushDeviceRequest(
|
||||
umengDeviceToken: 'push-token',
|
||||
platform: pb.Platform.PLATFORM_IOS,
|
||||
appVersion: '1.1.0',
|
||||
permissionGranted: true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(reply.deviceRecordId, 'device-record');
|
||||
final request = pb.RegisterPushDeviceRequest.fromBuffer(adapter.body!);
|
||||
expect(request.umengDeviceToken, 'push-token');
|
||||
expect(request.platform, pb.Platform.PLATFORM_IOS);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _PushAdapter implements HttpClientAdapter {
|
||||
List<int>? body;
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async {
|
||||
body = (options.data as List<int>).toList();
|
||||
return ResponseBody.fromBytes(
|
||||
pb.RegisterPushDeviceReply(
|
||||
deviceRecordId: 'device-record',
|
||||
).writeToBuffer(),
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/router/deep_link_service.dart';
|
||||
|
||||
void main() {
|
||||
setUp(DeepLinkService.resetForTest);
|
||||
|
||||
test('keeps FIFO events until the router is ready', () {
|
||||
DeepLinkService.configureTrustedHttpsHosts({'links.example.com'});
|
||||
DeepLinkService.handle('jinzhi://market');
|
||||
DeepLinkService.handleUri(Uri.parse('https://links.example.com/news?id=1'));
|
||||
expect(DeepLinkService.pendingCount, 2);
|
||||
|
||||
final navigated = <String>[];
|
||||
DeepLinkService.attachRouter((location) {
|
||||
navigated.add(location);
|
||||
return true;
|
||||
});
|
||||
|
||||
expect(navigated, ['/market', '/news?id=1']);
|
||||
expect(DeepLinkService.pendingCount, 0);
|
||||
});
|
||||
|
||||
test('rejects HTTPS links outside the exact trusted host allowlist', () {
|
||||
DeepLinkService.configureTrustedHttpsHosts({'links.example.com'});
|
||||
final navigated = <String>[];
|
||||
DeepLinkService.attachRouter((location) {
|
||||
navigated.add(location);
|
||||
return true;
|
||||
});
|
||||
|
||||
DeepLinkService.handleUri(Uri.parse('https://evil.example/news'));
|
||||
DeepLinkService.handleUri(Uri.parse('https://sub.links.example.com/news'));
|
||||
DeepLinkService.handleUri(Uri.parse('https://links.example.com:8443/news'));
|
||||
DeepLinkService.handleUri(Uri.parse('https://links.example.com/news'));
|
||||
|
||||
expect(navigated, ['/news']);
|
||||
expect(DeepLinkService.pendingCount, 0);
|
||||
});
|
||||
|
||||
test('disables HTTPS deep links when no production host is configured', () {
|
||||
final navigated = <String>[];
|
||||
DeepLinkService.attachRouter((location) {
|
||||
navigated.add(location);
|
||||
return true;
|
||||
});
|
||||
|
||||
DeepLinkService.handleUri(Uri.parse('https://links.example.com/news'));
|
||||
DeepLinkService.handle('jinzhi://news');
|
||||
|
||||
expect(navigated, ['/news']);
|
||||
});
|
||||
|
||||
test('deduplicates event ids even after the first event is consumed', () {
|
||||
final navigated = <String>[];
|
||||
DeepLinkService.attachRouter((location) {
|
||||
navigated.add(location);
|
||||
return true;
|
||||
});
|
||||
final payload = jsonEncode({
|
||||
'event_id': 'same-event',
|
||||
'deep_link': 'jinzhi://assets',
|
||||
});
|
||||
|
||||
DeepLinkService.handle(payload);
|
||||
DeepLinkService.handle(payload);
|
||||
|
||||
expect(navigated, ['/assets']);
|
||||
});
|
||||
|
||||
test('uses bounded queue and discards expired events', () {
|
||||
final old = DateTime.now().subtract(DeepLinkService.expiry * 2);
|
||||
DeepLinkService.enqueue(
|
||||
'jinzhi://assets',
|
||||
source: DeepLinkSource.appLink,
|
||||
receivedAt: old,
|
||||
);
|
||||
for (var index = 0; index < DeepLinkService.maxPending + 5; index++) {
|
||||
DeepLinkService.enqueue(
|
||||
'jinzhi://news?event_id=$index',
|
||||
source: DeepLinkSource.push,
|
||||
);
|
||||
}
|
||||
expect(DeepLinkService.pendingCount, DeepLinkService.maxPending);
|
||||
|
||||
final navigated = <String>[];
|
||||
DeepLinkService.attachRouter((location) {
|
||||
navigated.add(location);
|
||||
return true;
|
||||
});
|
||||
expect(navigated, hasLength(DeepLinkService.maxPending));
|
||||
expect(navigated, isNot(contains('/assets')));
|
||||
});
|
||||
|
||||
test('auth guard leaves the head pending until auth changes', () {
|
||||
var authenticated = false;
|
||||
final navigated = <String>[];
|
||||
DeepLinkService.attachRouter((location) {
|
||||
navigated.add(location);
|
||||
return true;
|
||||
}, authGuard: (_) => authenticated);
|
||||
DeepLinkService.handle('jinzhi://profile');
|
||||
expect(DeepLinkService.pendingCount, 1);
|
||||
|
||||
authenticated = true;
|
||||
DeepLinkService.notifyAuthChanged();
|
||||
expect(navigated, ['/profile']);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/secure_identity_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/storage_keys.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
@@ -14,7 +17,10 @@ void main() {
|
||||
});
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final secure = _MemorySecureStorage();
|
||||
final storage = SecureIdentityStorage(preferences, secureStorage: secure);
|
||||
final storage = SecureIdentityStorage(
|
||||
PreferencesStorage(preferences),
|
||||
secureStorage: secure,
|
||||
);
|
||||
|
||||
expect(await storage.restoreAccessToken(), 'legacy');
|
||||
expect(await secure.read('access_token'), 'legacy');
|
||||
@@ -24,17 +30,109 @@ void main() {
|
||||
);
|
||||
|
||||
test(
|
||||
'migrates the previous secure jwt key when no current token exists',
|
||||
'rejects an orphaned Keychain token when the install marker is absent',
|
||||
() async {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final secure = _MemorySecureStorage({'jwt_token': 'secure-legacy'});
|
||||
final storage = SecureIdentityStorage(preferences, secureStorage: secure);
|
||||
final storage = SecureIdentityStorage(
|
||||
PreferencesStorage(preferences),
|
||||
secureStorage: secure,
|
||||
);
|
||||
|
||||
expect(await storage.restoreAccessToken(), 'secure-legacy');
|
||||
expect(await secure.read('access_token'), 'secure-legacy');
|
||||
expect(await storage.restoreAccessToken(), isNull);
|
||||
expect(await secure.read('access_token'), isNull);
|
||||
expect(await secure.read('jwt_token'), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('restores a secure token only with the installation marker', () async {
|
||||
SharedPreferences.setMockInitialValues({storageAuthSessionActive: true});
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final secure = _MemorySecureStorage({secureAccessToken: 'active'});
|
||||
final storage = SecureIdentityStorage(
|
||||
PreferencesStorage(preferences),
|
||||
secureStorage: secure,
|
||||
);
|
||||
|
||||
expect(await storage.restoreAccessToken(), 'active');
|
||||
});
|
||||
|
||||
test('logout clears session secrets but preserves device identity', () async {
|
||||
SharedPreferences.setMockInitialValues({storageAuthSessionActive: true});
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final secure = _MemorySecureStorage({
|
||||
secureAccessToken: 'access',
|
||||
secureMallCredential: 'mall',
|
||||
secureRefreshToken: 'refresh',
|
||||
secureBiometricCredential: 'biometric',
|
||||
secureDeviceId: 'device',
|
||||
});
|
||||
final storage = SecureIdentityStorage(
|
||||
PreferencesStorage(preferences),
|
||||
secureStorage: secure,
|
||||
);
|
||||
|
||||
await storage.clearSessionCredentials();
|
||||
|
||||
expect(await secure.read(secureAccessToken), isNull);
|
||||
expect(await secure.read(secureMallCredential), isNull);
|
||||
expect(await secure.read(secureRefreshToken), isNull);
|
||||
expect(await secure.read(secureBiometricCredential), 'biometric');
|
||||
expect(await secure.read(secureDeviceId), 'device');
|
||||
|
||||
await storage.clearAccountCredentials();
|
||||
expect(await secure.read(secureBiometricCredential), isNull);
|
||||
expect(await secure.read(secureDeviceId), 'device');
|
||||
});
|
||||
|
||||
test(
|
||||
'serializes restore with logout so an old token cannot revive',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({storageAuthSessionActive: true});
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final secure = _BlockingReadSecureStorage({
|
||||
secureAccessToken: 'old-token',
|
||||
});
|
||||
final session = AuthSessionStorage(
|
||||
SecureIdentityStorage(
|
||||
PreferencesStorage(preferences),
|
||||
secureStorage: secure,
|
||||
),
|
||||
);
|
||||
|
||||
final restoring = session.restore();
|
||||
await secure.readStarted.future;
|
||||
final clearing = session.clear();
|
||||
secure.releaseRead.complete();
|
||||
|
||||
expect(await restoring, 'old-token');
|
||||
await clearing;
|
||||
expect(session.accessToken, isNull);
|
||||
expect(await secure.read(secureAccessToken), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('serializes restore with login so the new token wins', () async {
|
||||
SharedPreferences.setMockInitialValues({storageAuthSessionActive: true});
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final secure = _BlockingReadSecureStorage({secureAccessToken: 'old-token'});
|
||||
final session = AuthSessionStorage(
|
||||
SecureIdentityStorage(
|
||||
PreferencesStorage(preferences),
|
||||
secureStorage: secure,
|
||||
),
|
||||
);
|
||||
|
||||
final restoring = session.restore();
|
||||
await secure.readStarted.future;
|
||||
final saving = session.save('new-token');
|
||||
secure.releaseRead.complete();
|
||||
|
||||
expect(await restoring, 'old-token');
|
||||
await saving;
|
||||
expect(session.accessToken, 'new-token');
|
||||
expect(await secure.read(secureAccessToken), 'new-token');
|
||||
});
|
||||
}
|
||||
|
||||
class _MemorySecureStorage implements SecureKeyValueStorage {
|
||||
@@ -54,3 +152,21 @@ class _MemorySecureStorage implements SecureKeyValueStorage {
|
||||
_values[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
class _BlockingReadSecureStorage extends _MemorySecureStorage {
|
||||
_BlockingReadSecureStorage(super.initial);
|
||||
|
||||
final readStarted = Completer<void>();
|
||||
final releaseRead = Completer<void>();
|
||||
var _blocked = false;
|
||||
|
||||
@override
|
||||
Future<String?> read(String key) async {
|
||||
if (key == secureAccessToken && !_blocked) {
|
||||
_blocked = true;
|
||||
readStarted.complete();
|
||||
await releaseRead.future;
|
||||
}
|
||||
return super.read(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/features/ads/data/ads_api_client.dart';
|
||||
import 'package:jinzhi_flutter_app/models/pb/jinzhi/v1/ads.pb.dart' as pb;
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'Retrofit client decodes generated ad protobuf and encodes events',
|
||||
() async {
|
||||
final adapter = _AdsAdapter();
|
||||
final backend = AdsBackendApiClient(
|
||||
Dio(BaseOptions(responseType: ResponseType.bytes))
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
final client = AdsApiClient(backend);
|
||||
|
||||
final result = await client.getAdResult(splashAdPlacement);
|
||||
expect(result.enabled, isTrue);
|
||||
expect(result.ad?.title, '商城');
|
||||
expect(result.ad?.endAtMs, 4102444800000);
|
||||
|
||||
await client.report(result.ad!, 'impression');
|
||||
final report = pb.ReportAdEventRequest.fromBuffer(adapter.lastBody!);
|
||||
expect(report.eventName, 'impression');
|
||||
expect(report.adId, 'ad-1');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _AdsAdapter implements HttpClientAdapter {
|
||||
List<int>? lastBody;
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async {
|
||||
if (options.path == '/api/v1/ads') {
|
||||
return ResponseBody.fromBytes(
|
||||
pb.GetAdReply(
|
||||
enabled: true,
|
||||
dailyCap: 1,
|
||||
ad: pb.AdCreative(
|
||||
adId: 'ad-1',
|
||||
placement: splashAdPlacement,
|
||||
title: '商城',
|
||||
imageUrl: 'https://cdn.example.com/ad.png',
|
||||
h5Url: 'https://example.com',
|
||||
trackedUrl: 'https://example.com',
|
||||
mediaType: pb.MediaType.MEDIA_TYPE_IMAGE,
|
||||
openMode: pb.AdOpenMode.AD_OPEN_MODE_EXTERNAL,
|
||||
endAtMs: Int64(4102444800000),
|
||||
),
|
||||
).writeToBuffer(),
|
||||
200,
|
||||
);
|
||||
}
|
||||
lastBody = (options.data as List<int>).toList();
|
||||
return ResponseBody.fromBytes(pb.ReportAdEventReply().writeToBuffer(), 200);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/features/ads/data/ads_api_client.dart';
|
||||
import 'package:jinzhi_flutter_app/common/network/protobuf_wire_reader.dart';
|
||||
|
||||
void main() {
|
||||
test('protobuf strings are decoded as UTF-8', () {
|
||||
final value = utf8.encode('商城');
|
||||
final message = ProtoMessage(<int>[10, value.length, ...value]);
|
||||
final message = ProtobufWireMessage(<int>[10, value.length, ...value]);
|
||||
|
||||
expect(message.string(1), '商城');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/features/assets/data/mock_asset_repository.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
test('account deletion stays empty after repository recreation', () async {
|
||||
final preferences = PreferencesStorage(
|
||||
await SharedPreferences.getInstance(),
|
||||
);
|
||||
final repository = MockAssetRepository(preferences);
|
||||
final accountScope = MockAssetRepository.accountScope(userId: 'user-1');
|
||||
final initial = repository.loadHoldings(accountScope);
|
||||
expect(initial, isNotEmpty);
|
||||
expect(
|
||||
repository.loadHoldings(MockAssetRepository.guestAccountScope),
|
||||
isNotEmpty,
|
||||
);
|
||||
await repository.saveHoldings(accountScope, initial);
|
||||
|
||||
await repository.deleteAccountHoldings(accountScope);
|
||||
final recreated = MockAssetRepository(preferences);
|
||||
|
||||
expect(recreated.loadHoldings(accountScope), isEmpty);
|
||||
expect(
|
||||
recreated.loadHoldings(MockAssetRepository.guestAccountScope),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('holdings are isolated by account and deletion is scoped', () async {
|
||||
final preferences = PreferencesStorage(
|
||||
await SharedPreferences.getInstance(),
|
||||
);
|
||||
final repository = MockAssetRepository(preferences);
|
||||
final first = MockAssetRepository.accountScope(userId: 'user-1');
|
||||
final second = MockAssetRepository.accountScope(userId: 'user-2');
|
||||
final firstHoldings = repository.loadHoldings(first);
|
||||
final secondHoldings = repository.loadHoldings(second);
|
||||
await repository.saveHoldings(first, firstHoldings.take(1).toList());
|
||||
await repository.saveHoldings(second, secondHoldings.take(2).toList());
|
||||
|
||||
await repository.deleteAccountHoldings(first);
|
||||
|
||||
expect(repository.loadHoldings(first), isEmpty);
|
||||
expect(repository.loadHoldings(second), hasLength(2));
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/secure_identity_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/storage_keys.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/features/auth/application/auth_controller.dart';
|
||||
import 'package:jinzhi_flutter_app/features/auth/application/auth_session_coordinator.dart';
|
||||
import 'package:jinzhi_flutter_app/features/auth/data/auth_models.dart';
|
||||
import 'package:jinzhi_flutter_app/features/assets/data/mock_asset_repository.dart';
|
||||
import 'package:jinzhi_flutter_app/models/pb/jinzhi/v1/account.pb.dart' as pb;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -61,7 +66,7 @@ void main() {
|
||||
|
||||
await fixture.controller.retryBootstrap();
|
||||
|
||||
expect(fixture.controller.state.value, isA<GuestAuthState>());
|
||||
expect(fixture.controller.state.value, isA<SessionExpiredAuthState>());
|
||||
expect(await fixture.identity.readJwt(), isNull);
|
||||
});
|
||||
|
||||
@@ -81,6 +86,59 @@ void main() {
|
||||
expect(fixture.preferences.containsKey('jinzhi_mock_logged_in'), isFalse);
|
||||
});
|
||||
|
||||
test('restores cached profile before the background profile reply', () async {
|
||||
final fixture = await _Fixture.create();
|
||||
await fixture.identity.saveJwt('token');
|
||||
await fixture.preferences.setString(storageUserId, 'cached-user');
|
||||
await fixture.preferences.setString(storageUserPhone, '13900139000');
|
||||
fixture.profileCompleter = Completer<void>();
|
||||
|
||||
final bootstrap = fixture.controller.retryBootstrap();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final stale = fixture.controller.state.value! as LoggedInAuthState;
|
||||
expect(stale.user.id, 'cached-user');
|
||||
expect(stale.isVerified, isFalse);
|
||||
|
||||
fixture.profileCompleter!.complete();
|
||||
await bootstrap;
|
||||
expect(
|
||||
(fixture.controller.state.value! as LoggedInAuthState).isVerified,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps cached login on 403, 429, 5xx, and decode failures', () async {
|
||||
for (final failure in [403, 429, 503, -1]) {
|
||||
final fixture = await _Fixture.create();
|
||||
await fixture.identity.saveJwt('token');
|
||||
await fixture.preferences.setString(storageUserId, 'cached-user');
|
||||
fixture.profileFailureStatus = failure;
|
||||
|
||||
await fixture.controller.retryBootstrap();
|
||||
|
||||
expect(fixture.controller.state.value, isA<LoggedInAuthState>());
|
||||
expect(await fixture.identity.readJwt(), 'token');
|
||||
}
|
||||
});
|
||||
|
||||
test('logout preserves assets while account deletion removes them', () async {
|
||||
final logoutFixture = await _Fixture.create();
|
||||
await logoutFixture.identity.saveJwt('token');
|
||||
await logoutFixture.preferences.setString(storageJinzhiHoldings, 'assets');
|
||||
await logoutFixture.controller.logout();
|
||||
expect(
|
||||
logoutFixture.preferences.getString(storageJinzhiHoldings),
|
||||
'assets',
|
||||
);
|
||||
|
||||
final deleteFixture = await _Fixture.create();
|
||||
await deleteFixture.identity.saveJwt('token');
|
||||
await deleteFixture.preferences.setString(storageJinzhiHoldings, 'assets');
|
||||
await deleteFixture.controller.deleteAccount('123456');
|
||||
expect(deleteFixture.preferences.getString(storageJinzhiHoldings), isNull);
|
||||
});
|
||||
|
||||
test('keeps the verified one-key login state shape', () async {
|
||||
final fixture = await _Fixture.create();
|
||||
|
||||
@@ -106,20 +164,47 @@ class _Fixture {
|
||||
bool failDeletion = false;
|
||||
bool failProfile = false;
|
||||
bool profileUnauthorized = false;
|
||||
int? profileFailureStatus;
|
||||
Completer<void>? profileCompleter;
|
||||
String? deletedWithCode;
|
||||
|
||||
static Future<_Fixture> create() async {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final identity = SecureIdentityStorage(
|
||||
preferences,
|
||||
PreferencesStorage(preferences),
|
||||
secureStorage: _MemorySecureStorage(),
|
||||
);
|
||||
late _Fixture fixture;
|
||||
final dio = Dio()
|
||||
..interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
onRequest: (options, handler) async {
|
||||
if (options.path == '/api/v1/me' && options.method == 'GET') {
|
||||
if (fixture.profileCompleter case final completer?) {
|
||||
await completer.future;
|
||||
}
|
||||
if (fixture.profileFailureStatus case final status?) {
|
||||
if (status == -1) {
|
||||
handler.resolve(
|
||||
Response<List<int>>(
|
||||
requestOptions: options,
|
||||
statusCode: 200,
|
||||
data: const [255],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: options,
|
||||
response: Response<void>(
|
||||
requestOptions: options,
|
||||
statusCode: status,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (fixture.failProfile) {
|
||||
handler.reject(DioException(requestOptions: options));
|
||||
return;
|
||||
@@ -185,10 +270,17 @@ class _Fixture {
|
||||
},
|
||||
),
|
||||
);
|
||||
final preferencesStorage = PreferencesStorage(preferences);
|
||||
final sessionStorage = AuthSessionStorage(identity);
|
||||
final coordinator = AuthSessionCoordinator(
|
||||
sessionStorage,
|
||||
preferencesStorage,
|
||||
MockAssetRepository(preferencesStorage),
|
||||
);
|
||||
final controller = AuthController(
|
||||
dio,
|
||||
identityStorage: identity,
|
||||
preferences: preferences,
|
||||
preferences: preferencesStorage,
|
||||
coordinator: coordinator,
|
||||
autoBootstrap: false,
|
||||
);
|
||||
fixture = _Fixture(
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/network/api_exception.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/secure_identity_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/storage_keys.dart';
|
||||
import 'package:jinzhi_flutter_app/features/auth/application/auth_session_coordinator.dart';
|
||||
import 'package:jinzhi_flutter_app/features/assets/data/mock_asset_repository.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'concurrent unauthorized responses expire one session only once',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final preferences = PreferencesStorage(
|
||||
await SharedPreferences.getInstance(),
|
||||
);
|
||||
final identity = SecureIdentityStorage(
|
||||
preferences,
|
||||
secureStorage: _MemorySecureStorage(),
|
||||
);
|
||||
final session = AuthSessionStorage(identity);
|
||||
final assets = MockAssetRepository(preferences);
|
||||
final coordinator = AuthSessionCoordinator(session, preferences, assets);
|
||||
await coordinator.startSession('token');
|
||||
final generation = coordinator.generation;
|
||||
var notifications = 0;
|
||||
coordinator.addListener((_) => notifications++);
|
||||
final request = RequestOptions(
|
||||
path: '/protected',
|
||||
extra: {authSessionGenerationExtra: generation},
|
||||
);
|
||||
final exception = ApiException(
|
||||
kind: ApiExceptionKind.backend,
|
||||
httpStatus: 400,
|
||||
reason: 'TOKEN_EXPIRED',
|
||||
requestOptions: request,
|
||||
);
|
||||
|
||||
await Future.wait([
|
||||
coordinator.expireIfNeeded(exception),
|
||||
coordinator.expireIfNeeded(exception),
|
||||
]);
|
||||
|
||||
expect(notifications, 1);
|
||||
expect(await identity.readJwt(), isNull);
|
||||
expect(await coordinator.expireIfNeeded(exception), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('account deletion upgrades an in-flight expiration', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
storageUserId: 'user-1',
|
||||
storageUserPhone: '13800138000',
|
||||
storageJinzhiHoldings: 'legacy-assets',
|
||||
});
|
||||
final preferences = PreferencesStorage(
|
||||
await SharedPreferences.getInstance(),
|
||||
);
|
||||
final secure = _MemorySecureStorage({
|
||||
secureBiometricCredential: 'biometric',
|
||||
});
|
||||
final identity = SecureIdentityStorage(preferences, secureStorage: secure);
|
||||
final session = AuthSessionStorage(identity);
|
||||
final assets = MockAssetRepository(preferences);
|
||||
final accountScope = MockAssetRepository.accountScope(userId: 'user-1');
|
||||
await assets.saveHoldings(accountScope, assets.loadHoldings(accountScope));
|
||||
final coordinator = AuthSessionCoordinator(session, preferences, assets);
|
||||
await coordinator.startSession('token');
|
||||
final notifications = <SessionEndReason>[];
|
||||
coordinator.addListener(notifications.add);
|
||||
|
||||
final expiring = coordinator.end(SessionEndReason.expired);
|
||||
final deleting = coordinator.accountDeleted();
|
||||
await Future.wait([expiring, deleting]);
|
||||
|
||||
expect(notifications, [SessionEndReason.accountDeleted]);
|
||||
expect(await secure.read(secureBiometricCredential), isNull);
|
||||
expect(assets.loadHoldings(accountScope), isEmpty);
|
||||
expect(preferences.containsKey(storageJinzhiHoldings), isFalse);
|
||||
});
|
||||
|
||||
test('a new login waits for an in-flight session end', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final preferences = PreferencesStorage(
|
||||
await SharedPreferences.getInstance(),
|
||||
);
|
||||
final identity = SecureIdentityStorage(
|
||||
preferences,
|
||||
secureStorage: _MemorySecureStorage(),
|
||||
);
|
||||
final session = AuthSessionStorage(identity);
|
||||
final coordinator = AuthSessionCoordinator(
|
||||
session,
|
||||
preferences,
|
||||
MockAssetRepository(preferences),
|
||||
);
|
||||
final accountScope = MockAssetRepository.accountScope(userId: 'user-1');
|
||||
await preferences.setString(storageUserId, 'user-1');
|
||||
final assets = MockAssetRepository(preferences);
|
||||
await assets.saveHoldings(accountScope, assets.loadHoldings(accountScope));
|
||||
await coordinator.startSession('old-token');
|
||||
|
||||
final ending = coordinator.logout();
|
||||
final starting = coordinator.startSession('new-token');
|
||||
await Future.wait([ending, starting]);
|
||||
|
||||
expect(session.accessToken, 'new-token');
|
||||
expect(await identity.readJwt(), 'new-token');
|
||||
expect(assets.loadHoldings(accountScope), isNotEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
class _MemorySecureStorage implements SecureKeyValueStorage {
|
||||
_MemorySecureStorage([Map<String, String>? initial]) : values = {...?initial};
|
||||
|
||||
final Map<String, String> values;
|
||||
|
||||
@override
|
||||
Future<void> delete(String key) async => values.remove(key);
|
||||
@override
|
||||
Future<String?> read(String key) async => values[key];
|
||||
@override
|
||||
Future<void> write(String key, String value) async => values[key] = value;
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/features/mall/application/mall_entry_promo_store.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
test('records each first-install mall promotion exposure once', () async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final store = MallEntryPromoStore(await SharedPreferences.getInstance());
|
||||
final store = MallEntryPromoStore(
|
||||
PreferencesStorage(await SharedPreferences.getInstance()),
|
||||
);
|
||||
|
||||
expect(store.hasOpenedMall, isFalse);
|
||||
expect(store.hasSeenHomeBadge, isFalse);
|
||||
|
||||
Reference in new issue
Block a user