Merge branch 'dev' into happi/dev/database-update
# Conflicts: # apps/im_app/lib/data/models/user_dto.dart # apps/im_app/lib/data/remote/login_request.dart # apps/im_app/lib/features/chat/presentation/chat_db_test_view_model.dart # apps/im_app/lib/features/chat/view/chat_db_test_page.dart # apps/im_app/lib/features/login/presentation/login_view_model.dart
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@ plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
@@ -38,5 +38,10 @@ end
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
target.build_configurations.each do |config|
|
||||
# 对没有在 podspec 里声明 swift_version 的 pod 设置兜底版本。
|
||||
# 已有 swift_version 的 pod(含第三方如 Agora)CocoaPods 优先使用其 podspec 值,不受影响。
|
||||
config.build_settings['SWIFT_VERSION'] ||= '6.2'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -164,7 +164,6 @@
|
||||
1C416905D0EA345032C4E612 /* Pods-RunnerTests.release.xcconfig */,
|
||||
9538107A41BCB5B5D84FBAF3 /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -489,7 +488,7 @@
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
@@ -508,7 +507,7 @@
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.2;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Debug;
|
||||
@@ -524,7 +523,7 @@
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.2;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Release;
|
||||
@@ -540,7 +539,7 @@
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.2;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Profile;
|
||||
@@ -678,7 +677,7 @@
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
@@ -705,7 +704,7 @@
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Flutter
|
||||
@preconcurrency import Flutter
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
|
||||
override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
@@ -16,9 +16,11 @@ import UIKit
|
||||
sceneConfig.delegateClass = SceneDelegate.self
|
||||
return sceneConfig
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FlutterImplicitEngineDelegate
|
||||
|
||||
// FlutterImplicitEngineDelegate 来自 Flutter ObjC 框架,尚未标注 @MainActor,
|
||||
// 用 @preconcurrency 抑制 Swift 6 ConformanceIsolation 错误。
|
||||
extension AppDelegate: @preconcurrency FlutterImplicitEngineDelegate {
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
}
|
||||
|
||||
@@ -23,11 +23,18 @@ class AuthNotifier extends ChangeNotifier {
|
||||
|
||||
void login() {
|
||||
_isLoggedIn = true;
|
||||
// TODO: 接入 cipher_guard_sdk 后,在此处完成 RSA 密钥注入:
|
||||
// 1. 从安全存储(keychain / secure storage)读取公私钥对(只读一次)
|
||||
// 2. cipherSdk.setActiveKeyPair(publicKey: pubPem, privateKey: privPem)
|
||||
// 须在 notifyListeners() 之前完成,确保路由跳转后 onEncryptRequest 回调触发时密钥已就绪。
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void logout() {
|
||||
_isLoggedIn = false;
|
||||
// TODO: 接入 cipher_guard_sdk 后,退出登录时清除内存密钥:
|
||||
// cipherSdk.clearActiveKeyPair()
|
||||
// cipherSdk.clearDerivedKeyCache()
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -37,9 +44,7 @@ class AuthNotifier extends ChangeNotifier {
|
||||
/// 使用 [Provider] 持有 [AuthNotifier] 单例。
|
||||
/// go_router 通过 [GoRouter.refreshListenable] 直接监听 [AuthNotifier](ChangeNotifier),
|
||||
/// Riverpod 侧不需要响应式更新(导航由 go_router 接管)。
|
||||
final authNotifierProvider = Provider<AuthNotifier>(
|
||||
(ref) => AuthNotifier(),
|
||||
);
|
||||
final authNotifierProvider = Provider<AuthNotifier>((ref) => AuthNotifier());
|
||||
|
||||
// ── 主题 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import 'package:networks_sdk/networks_sdk.dart';
|
||||
import '../../core/foundation/api_paths.dart';
|
||||
import '../../core/foundation/config.dart';
|
||||
import '../../core/foundation/constants.dart';
|
||||
import '../../core/foundation/errors.dart';
|
||||
import '../../core/foundation/utils.dart';
|
||||
import '../../core/services/network_monitor.dart';
|
||||
import '../../core/services/socket_manager.dart';
|
||||
|
||||
@@ -47,6 +49,21 @@ final networkMonitorProvider = Provider<NetworkMonitor>((ref) {
|
||||
return monitor;
|
||||
});
|
||||
|
||||
// ── Token 更新事件流 ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Token 更新事件流
|
||||
///
|
||||
/// apiConfigProvider.onTokenUpdated → 推送新 token 到此流
|
||||
/// socketManagerProvider → 监听此流 → 同步 token 到 WebSocket
|
||||
/// onBeforeReconnect 中刷新 token 后调用 apiConfig.updateToken → tokenStream.add,
|
||||
/// 需要同步传播到 socketManager.updateToken → socketClient._currentToken,
|
||||
/// 确保随后的 _doConnect() 使用新 token。异步模式下 _doConnect 会在 stream
|
||||
final _tokenUpdateStreamProvider = Provider<StreamController<String>>((ref) {
|
||||
final controller = StreamController<String>.broadcast(sync: true);
|
||||
ref.onDispose(controller.close);
|
||||
return controller;
|
||||
});
|
||||
|
||||
// ── HTTP 基础设施 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// API 配置 Provider(全局单例)
|
||||
@@ -58,15 +75,18 @@ final networkMonitorProvider = Provider<NetworkMonitor>((ref) {
|
||||
/// 请求前先判断网络状态,无网络时直接抛 [ApiError.noNetworkConnection]。
|
||||
final apiConfigProvider = Provider<ApiConfig>((ref) {
|
||||
final networkMonitor = ref.read(networkMonitorProvider);
|
||||
final tokenStream = ref.read(_tokenUpdateStreamProvider);
|
||||
|
||||
return ApiConfig(
|
||||
baseURL: AppConfig.apiBaseUrl,
|
||||
platformHeaders: {
|
||||
'Platform': 'Android', // TODO: 运行时从平台 API 获取
|
||||
'Platform': 'Android', // TODO: 运行时从 platform API 获取
|
||||
'client-version': '1.0.0', // TODO: 运行时从 package_info 获取
|
||||
'Channel': '', // TODO: 从 AppConfig 读取渠道标识
|
||||
'lang': 'zh-CN', // TODO: 从 l10n_sdk 或系统 locale 动态获取
|
||||
},
|
||||
tokenExpiredCodes: {30002, 30003, 30124},
|
||||
forceLogoutCodes: {30125},
|
||||
tokenExpiredCodes: ApiErrorCodes.tokenExpiredCodes,
|
||||
forceLogoutCodes: ApiErrorCodes.forceLogoutCodes,
|
||||
onForceLogout: () {
|
||||
// TODO: 清除登录态,跳转登录页
|
||||
},
|
||||
@@ -74,7 +94,33 @@ final apiConfigProvider = Provider<ApiConfig>((ref) {
|
||||
// TODO: App 层刷新 token 逻辑
|
||||
return null;
|
||||
},
|
||||
onTokenUpdated: (newToken) {
|
||||
// 通过事件流同步到 WebSocket,避免直接引用 socketManagerProvider 造成循环依赖
|
||||
tokenStream.add(newToken);
|
||||
},
|
||||
onCheckNetworkAvailable: () async => networkMonitor.isConnected,
|
||||
// TODO: 接入 cipher_guard_sdk 后注入请求加密回调。
|
||||
// 前提:AuthNotifier.login() 中已完成 cipherSdk.setActiveKeyPair(pub, priv)。
|
||||
// 示例:
|
||||
// onEncryptRequest: (path, headers, body) async {
|
||||
// final encryptedKey = await cipherSdk.encryptSessionKeyWithActiveKey(
|
||||
// sessionKey: currentSessionKey,
|
||||
// );
|
||||
// return EncryptedRequest(body: encryptedBody, headers: {'X-Key': encryptedKey});
|
||||
// },
|
||||
onEncryptRequest: null,
|
||||
// TODO: 接入 cipher_guard_sdk 后注入响应解密回调。
|
||||
// 前提:与 onEncryptRequest 配套,服务端响应同样加密时启用。
|
||||
// 示例:
|
||||
// onDecryptResponse: (data) async {
|
||||
// final plaintext = await cipherSdk.decryptMessage(encryptedData: data as String, ...);
|
||||
// return jsonDecode(plaintext) as Map<String, dynamic>;
|
||||
// },
|
||||
onDecryptResponse: null,
|
||||
onBusinessError: null, // TODO: 接入业务错误统一处理(弹窗 / Toast / 跳转等)
|
||||
onTransformResponse:
|
||||
null, // TODO: 如后端响应格式非标准,在此归一化为 { code, data, message }
|
||||
onGetTokenExpiry: parseJwtExpiry,
|
||||
maxRetries: AppConstants.maxRetries,
|
||||
retryBaseDelay: AppConstants.retryBaseDelay,
|
||||
onLog: (message, {tag}) {
|
||||
@@ -94,16 +140,47 @@ final networkSdkApiProvider = Provider<NetworksSdkApi>((ref) {
|
||||
|
||||
// ── WebSocket 基础设施 ────────────────────────────────────────────────────────
|
||||
|
||||
/// SocketConfig Provider(全局单例)
|
||||
/// SocketConfig Provider(内部使用,不对外暴露)
|
||||
///
|
||||
/// 与 apiConfigProvider 对称,通过回调注入 App 层能力,
|
||||
/// SDK 内部不调用其他 SDK。
|
||||
final socketConfigProvider = Provider<SocketConfig>((ref) {
|
||||
final _socketConfigProvider = Provider<SocketConfig>((ref) {
|
||||
final networkMonitor = ref.read(networkMonitorProvider);
|
||||
|
||||
return SocketConfig(
|
||||
maxReconnectAttempts: AppConstants.maxRetries,
|
||||
maxReconnectDelay: AppConstants.maxReconnectDelay,
|
||||
unlimitedReconnect: true, // IM 场景始终保持连接
|
||||
onBuildConnectUrl:
|
||||
null, // TODO: 接入 cipher_guard_sdk 后注入 WS URL 加密(路径/token/cipher 参数)
|
||||
onEncryptMessage: null, // TODO: 接入 cipher_guard_sdk 后注入消息加密回调
|
||||
onDecryptMessage: null, // TODO: 接入 cipher_guard_sdk 后注入消息解密回调
|
||||
onBeforeReconnect: () async {
|
||||
// SocketClient 内部重连(心跳超时、stream onDone)前调用。
|
||||
// 与 SocketManager.onBeforeReconnect 职责相同:检查 token 并按需刷新。
|
||||
// 刷新后通过 sync stream 同步传播到 SocketClient._currentToken,
|
||||
// 确保随后的 _doConnect() 使用新 token。
|
||||
final apiConfig = ref.read(apiConfigProvider);
|
||||
final currentToken = apiConfig.token;
|
||||
if (currentToken == null || apiConfig.onGetTokenExpiry == null) return;
|
||||
|
||||
final expiry = apiConfig.onGetTokenExpiry!(currentToken);
|
||||
if (expiry == null) return;
|
||||
|
||||
final remaining = expiry.difference(DateTime.now());
|
||||
if (remaining > apiConfig.proactiveRefreshThreshold) return;
|
||||
|
||||
// ignore: avoid_print
|
||||
print(
|
||||
'[Socket] Token expiring in ${remaining.inMinutes}min, refreshing before reconnect',
|
||||
);
|
||||
final newToken = await apiConfig.onTokenRefresh?.call();
|
||||
if (newToken != null && newToken.isNotEmpty) {
|
||||
// updateToken → onTokenUpdated → sync stream → manager.updateToken
|
||||
// → _client.updateToken → socketClient._currentToken 同步更新
|
||||
apiConfig.updateToken(newToken);
|
||||
}
|
||||
},
|
||||
onLog: (message, {tag}) {
|
||||
// ignore: avoid_print
|
||||
print('[${tag ?? 'Socket'}] $message');
|
||||
@@ -114,12 +191,11 @@ final socketConfigProvider = Provider<SocketConfig>((ref) {
|
||||
);
|
||||
});
|
||||
|
||||
/// SocketClient Provider(全局单例)
|
||||
/// SocketClient Provider(内部使用,不对外暴露)
|
||||
///
|
||||
/// 与 apiClientProvider 对称。
|
||||
final socketClientProvider = Provider<NetworksMessagingApi>((ref)
|
||||
{
|
||||
final config = ref.read(socketConfigProvider);
|
||||
/// 与 networkSdkApiProvider 对称。
|
||||
final _socketClientProvider = Provider<NetworksMessagingApi>((ref) {
|
||||
final config = ref.read(_socketConfigProvider);
|
||||
return NetworksMessagingApi()..initialize(config);
|
||||
});
|
||||
|
||||
@@ -139,17 +215,44 @@ final socketClientProvider = Provider<NetworksMessagingApi>((ref)
|
||||
/// 网络状态变化由 [networkMonitorProvider](公共服务)驱动,
|
||||
/// 自动触发断连/重连。
|
||||
///
|
||||
/// Token 更新由 [_tokenUpdateStreamProvider] 事件流驱动,
|
||||
/// HTTP 层刷新 token 后自动同步到 WebSocket。
|
||||
///
|
||||
/// onMessageTransform 参考 HTTP 层 onTokenRefresh 的回调模式:
|
||||
/// 后续接入加解密 SDK 时,在此注入解密回调,
|
||||
/// SDK 内部不调用其他 SDK。
|
||||
final socketManagerProvider = Provider<SocketManager>((ref) {
|
||||
final client = ref.read(socketClientProvider);
|
||||
final client = ref.read(_socketClientProvider);
|
||||
final networkMonitor = ref.read(networkMonitorProvider);
|
||||
final apiConfig = ref.read(apiConfigProvider);
|
||||
final tokenStream = ref.read(_tokenUpdateStreamProvider);
|
||||
|
||||
final manager = SocketManager(
|
||||
client: client,
|
||||
wsUrl: _buildWsUrl(AppConfig.apiBaseUrl),
|
||||
disconnectInBackground: false, // 所有平台后台保活,心跳不停、连接不断
|
||||
onMessageTransform: null, // TODO: 接入加解密 SDK 后注入解密回调
|
||||
onBeforeReconnect: () async {
|
||||
// 重连前检查 token 是否即将过期,是则主动刷新
|
||||
final currentToken = apiConfig.token;
|
||||
if (currentToken == null || apiConfig.onGetTokenExpiry == null) return;
|
||||
|
||||
final expiry = apiConfig.onGetTokenExpiry!(currentToken);
|
||||
if (expiry == null) return;
|
||||
|
||||
final remaining = expiry.difference(DateTime.now());
|
||||
if (remaining > apiConfig.proactiveRefreshThreshold) return;
|
||||
|
||||
// ignore: avoid_print
|
||||
print(
|
||||
'[SocketManager] Token expiring in ${remaining.inMinutes}min, refreshing before reconnect',
|
||||
);
|
||||
final newToken = await apiConfig.onTokenRefresh?.call();
|
||||
if (newToken != null && newToken.isNotEmpty) {
|
||||
// updateToken 触发 onTokenUpdated → tokenStream → socketManager.updateToken
|
||||
apiConfig.updateToken(newToken);
|
||||
}
|
||||
},
|
||||
onCheckNetworkAvailable: () async => networkMonitor.isConnected,
|
||||
onLog: (message, {tag}) {
|
||||
// ignore: avoid_print
|
||||
@@ -157,13 +260,19 @@ final socketManagerProvider = Provider<SocketManager>((ref) {
|
||||
},
|
||||
);
|
||||
|
||||
// 监听 token 更新事件 → 同步到 WebSocket
|
||||
final tokenSub = tokenStream.stream.listen((newToken) {
|
||||
manager.updateToken(newToken);
|
||||
});
|
||||
|
||||
// 监听网络状态变化 → 驱动 SocketManager 断连/重连
|
||||
final subscription = networkMonitor.onStatusChanged.listen((isAvailable) {
|
||||
final networkSub = networkMonitor.onStatusChanged.listen((isAvailable) {
|
||||
manager.handleNetworkStatusChanged(isAvailable: isAvailable);
|
||||
});
|
||||
|
||||
ref.onDispose(() {
|
||||
subscription.cancel();
|
||||
tokenSub.cancel();
|
||||
networkSub.cancel();
|
||||
unawaited(manager.dispose());
|
||||
});
|
||||
|
||||
@@ -215,23 +324,57 @@ String _buildWsUrl(String httpBaseUrl) {
|
||||
// Provider 链路:
|
||||
//
|
||||
// networkMonitorProvider(公共服务,HTTP + WS 共用)
|
||||
// ├── apiConfigProvider → apiClientProvider ← HTTP 层
|
||||
// └── socketConfigProvider → socketClientProvider ← WS 层
|
||||
// ├── apiConfigProvider → networkSdkApiProvider ← HTTP 层
|
||||
// └── _socketConfigProvider → _socketClientProvider ← WS 层(内部)
|
||||
// → socketManagerProvider
|
||||
//
|
||||
// _tokenUpdateStreamProvider(打破循环引用的中间层)
|
||||
// ← apiConfigProvider.onTokenUpdated 推送
|
||||
// → socketManagerProvider 监听 → socketManager.updateToken()
|
||||
//
|
||||
// 网络事件驱动链路:
|
||||
//
|
||||
// connectivity_plus(平台网络事件)
|
||||
// → NetworkMonitor.onStatusChanged(true / false)
|
||||
// → SocketManager.handleNetworkStatusChanged()
|
||||
// → 断网: disconnect()
|
||||
// → 恢复: connect(token: lastToken)
|
||||
// → 恢复: onBeforeReconnect → connect(token: lastToken)
|
||||
//
|
||||
// 前后台事件驱动链路:
|
||||
//
|
||||
// WidgetsBindingObserver(App 层 app.dart)
|
||||
// → SocketManager.onEnterBackground() → disconnect
|
||||
// → SocketManager.onEnterForeground() → reconnect
|
||||
// → SocketManager.onEnterBackground()
|
||||
// disconnectInBackground=false → 完全保活,心跳不停(本项目默认)
|
||||
// disconnectInBackground=true → disconnect + 暂停心跳(省电模式)
|
||||
// → SocketManager.onEnterForeground()
|
||||
// 保活模式 → 检查连接健康,异常则重连
|
||||
// 断连模式 → onBeforeReconnect → reconnect
|
||||
//
|
||||
// Token 刷新 → WebSocket 同步链路:
|
||||
//
|
||||
// RetryInterceptor 检测 token 过期
|
||||
// → TokenRefreshManager.refreshIfNeeded()
|
||||
// → apiConfig.updateToken(newToken)
|
||||
// → onTokenUpdated(newToken)
|
||||
// → _tokenUpdateStream.add(newToken)
|
||||
// → socketManager.updateToken(newToken) // 不断连,下次重连自动用新 token
|
||||
//
|
||||
// 主动 token 刷新(重连前,两个层级):
|
||||
//
|
||||
// SocketManager 层(前台恢复 / 网络恢复触发):
|
||||
// SocketManager.onBeforeReconnect()
|
||||
// → 解析 JWT exp → 距过期 < 阈值
|
||||
// → apiConfig.onTokenRefresh() → 刷新
|
||||
// → apiConfig.updateToken(newToken)
|
||||
// → sync stream → manager.updateToken → _lastToken 更新
|
||||
// → _client.connect(token: _lastToken) 使用新 token
|
||||
//
|
||||
// SocketClient 层(心跳超时 / stream onDone 触发):
|
||||
// SocketConfig.onBeforeReconnect()
|
||||
// → 同上逻辑:检查 JWT exp → 刷新 → apiConfig.updateToken
|
||||
// → sync stream → manager.updateToken → _client.updateToken
|
||||
// → socketClient._currentToken 同步更新
|
||||
// → _doConnect() 使用新 token
|
||||
//
|
||||
// Repository 直接注入 ApiClient,通过回调注入其他 SDK 能力:
|
||||
//
|
||||
@@ -313,7 +456,7 @@ String _buildWsUrl(String httpBaseUrl) {
|
||||
// final authRepositoryProvider = Provider((ref) {
|
||||
// final apiConfig = ref.read(apiConfigProvider);
|
||||
// return AuthRepositoryImpl(
|
||||
// client: ref.read(apiClientProvider), // 直接注入
|
||||
// client: ref.read(networkSdkApiProvider), // 注入 Facade 接口
|
||||
// onTokenUpdate: (token) {
|
||||
// apiConfig.updateToken(token); // 内存(network_sdk)
|
||||
// // secureStorage.saveToken(token); // 持久化(crypto_sdk)
|
||||
@@ -400,5 +543,5 @@ String _buildWsUrl(String httpBaseUrl) {
|
||||
// Upload B: 二进制上传到 S3 presigned URL
|
||||
// @override String get path => presignedURL; // 完整 URL,不拼 baseURL
|
||||
// @override Object? get uploadData => bytes; // Uint8List
|
||||
// @override decodeResponse(response) { ... } // S3 不走标准信封
|
||||
// @override decodeResponse(response) { ... } // S3 不走标准响应格式
|
||||
//
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/// API 错误码常量
|
||||
///
|
||||
/// 集中管理后端业务错误码,避免散落在各处硬编码。
|
||||
/// 按业务域分组,命名风格对齐后端定义。
|
||||
///
|
||||
/// 使用方式:
|
||||
/// ```dart
|
||||
/// ApiConfig(
|
||||
/// tokenExpiredCodes: ApiErrorCodes.tokenExpiredCodes,
|
||||
/// forceLogoutCodes: ApiErrorCodes.forceLogoutCodes,
|
||||
/// )
|
||||
/// ```
|
||||
class ApiErrorCodes {
|
||||
ApiErrorCodes._();
|
||||
|
||||
// ── 认证(30001-30009)──
|
||||
|
||||
/// Token 无效
|
||||
static const int tokenInvalid = 30002;
|
||||
|
||||
/// JWT 无效
|
||||
static const int jwtInvalid = 30003;
|
||||
|
||||
/// 签名方法错误
|
||||
static const int signingMethodError = 30008;
|
||||
|
||||
/// 密钥解析失败
|
||||
static const int parsingKeyError = 30009;
|
||||
|
||||
/// Session 无效
|
||||
static const int sessionInvalid = 30124;
|
||||
|
||||
/// Refresh Token 失效
|
||||
static const int refreshTokenFailed = 30125;
|
||||
|
||||
/// 账号在其他设备登录
|
||||
static const int loggedInAnotherDevice = 30006;
|
||||
|
||||
// ── 错误码集合 ──
|
||||
|
||||
/// Token 过期错误码集合 — 触发自动刷新 Token
|
||||
static const Set<int> tokenExpiredCodes = {
|
||||
tokenInvalid,
|
||||
jwtInvalid,
|
||||
sessionInvalid,
|
||||
};
|
||||
|
||||
/// 强制登出错误码集合 — 触发退出登录流程
|
||||
static const Set<int> forceLogoutCodes = {refreshTokenFailed};
|
||||
|
||||
/// 踢下线错误码集合 — 触发踢下线 UI 提示
|
||||
static const Set<int> kickOffCodes = {
|
||||
loggedInAnotherDevice,
|
||||
signingMethodError,
|
||||
parsingKeyError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
|
||||
|
||||
/// JWT token 过期时间解析
|
||||
///
|
||||
/// 使用 dart_jsonwebtoken 解码 JWT payload,提取 `exp` claim 返回过期时间。
|
||||
/// 返回 null 表示无法解析(非 JWT 格式或缺少 exp 字段)。
|
||||
///
|
||||
/// 只读取 payload,不验证签名(验证是服务端的事)。
|
||||
///
|
||||
/// 用于 [ApiConfig.onGetTokenExpiry] 回调,启用 token 主动刷新:
|
||||
/// 距过期不足阈值时提前刷新,避免带过期 token 发请求或重连。
|
||||
///
|
||||
/// ```dart
|
||||
/// final expiry = parseJwtExpiry('eyJhbGci...');
|
||||
/// if (expiry != null) {
|
||||
/// final remaining = expiry.difference(DateTime.now());
|
||||
/// print('Token expires in ${remaining.inMinutes} min');
|
||||
/// }
|
||||
/// ```
|
||||
DateTime? parseJwtExpiry(String token) {
|
||||
try {
|
||||
final jwt = JWT.decode(token);
|
||||
final payload = jwt.payload;
|
||||
if (payload is! Map<String, dynamic>) return null;
|
||||
|
||||
final exp = payload['exp'];
|
||||
if (exp is! int) return null;
|
||||
return DateTime.fromMillisecondsSinceEpoch(exp * 1000);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
|
||||
import 'network_backoff_debouncer.dart';
|
||||
@@ -10,15 +9,14 @@ import 'network_backoff_debouncer.dart';
|
||||
/// 参考 HTTP 层 onTokenRefresh 的回调注入模式。
|
||||
/// App 层在 Provider 装配时注入解密/解析逻辑,
|
||||
/// 不在 SDK 内部调用加解密 SDK。
|
||||
typedef MessageTransformer = Map<String, dynamic> Function(
|
||||
Map<String, dynamic> raw,
|
||||
);
|
||||
typedef MessageTransformer =
|
||||
Map<String, dynamic> Function(Map<String, dynamic> raw);
|
||||
|
||||
/// WebSocket 连接管理
|
||||
///
|
||||
/// 在 SocketClient(SDK 底层能力)之上封装:
|
||||
/// - 连接/断连生命周期(登录连接、登出断连)
|
||||
/// - 前后台生命周期(后台断连省电、前台自动重连)
|
||||
/// - 前后台生命周期(两种模式:后台断连 / 后台保活)
|
||||
/// - 网络状态响应(断网断连、恢复网络立即重连)
|
||||
/// - 操作前置检查(网络可用性 + 后台状态)
|
||||
/// - 消息预处理管道(通过 [onMessageTransform] 回调注入解密等)
|
||||
@@ -39,19 +37,26 @@ typedef MessageTransformer = Map<String, dynamic> Function(
|
||||
///
|
||||
/// ```
|
||||
/// 登录成功 → connect(token) → 前置检查 → 建立连接
|
||||
/// App 进后台 → onEnterBackground() → 断开连接(省电)
|
||||
/// App 回前台 → onEnterForeground() → 检查网络 → 自动重连
|
||||
///
|
||||
/// ── disconnectInBackground = true(后台断连模式)──
|
||||
/// App 进后台 → onEnterBackground() → 暂停心跳 + 断开连接(省电)
|
||||
/// App 回前台 → onEnterForeground() → 恢复心跳 → onBeforeReconnect → 重连
|
||||
///
|
||||
/// ── disconnectInBackground = false(后台保活模式,本项目默认)──
|
||||
/// App 进后台 → onEnterBackground() → 不操作,心跳不停、连接不断
|
||||
/// App 回前台 → onEnterForeground() → 检查连接健康,异常则重连
|
||||
///
|
||||
/// 网络丢失 → handleNetworkLost() → 断开连接
|
||||
/// 网络恢复 → handleNetworkRestored() → 退避重连(防抖动)
|
||||
/// 网络恢复 → handleNetworkRestored() → 退避 → onBeforeReconnect → 重连
|
||||
/// 登出 → disconnect() → 断开连接,清除 token
|
||||
/// ```
|
||||
///
|
||||
/// ## 前置检查策略
|
||||
///
|
||||
/// 所有会发起网络操作的方法都先检查前置条件:
|
||||
/// - connect → 检查网络可用性 + 是否在后台
|
||||
/// - send / sendString → 检查连接状态 + 是否在后台
|
||||
/// - onEnterForeground 重连 → 检查网络可用性
|
||||
/// - connect → 检查网络可用性 + 是否在后台(仅 disconnectInBackground=true 时拦截)
|
||||
/// - send / sendString → 检查连接状态 + 是否在后台(仅 disconnectInBackground=true 时拦截)
|
||||
/// - onEnterForeground / 网络恢复重连 → 检查网络可用性 + onBeforeReconnect
|
||||
class SocketManager {
|
||||
final NetworksMessagingApi _client;
|
||||
final String _wsUrl;
|
||||
@@ -70,6 +75,22 @@ class SocketManager {
|
||||
/// 连接和重连前调用,无网络时跳过操作并标记恢复时重试。
|
||||
final Future<bool> Function()? onCheckNetworkAvailable;
|
||||
|
||||
/// 重连前回调
|
||||
///
|
||||
/// 在 WebSocket 重连前调用(前台恢复、网络恢复),App 层用于:
|
||||
/// - 检查并刷新即将过期的 token
|
||||
/// - 更新连接参数
|
||||
///
|
||||
/// 回调完成后才发起实际重连。
|
||||
final Future<void> Function()? onBeforeReconnect;
|
||||
|
||||
/// 进后台时是否断开连接
|
||||
///
|
||||
/// true(SDK 默认)— 后台断连省电,由 push 通知兜底,前台恢复时自动重连。
|
||||
/// false(本项目使用)— 后台保持连接,心跳不停、请求不停,最大程度保活。
|
||||
/// 回前台时检查连接健康,异常则触发重连。
|
||||
final bool disconnectInBackground;
|
||||
|
||||
/// 日志回调
|
||||
final void Function(String message, {String? tag})? onLog;
|
||||
|
||||
@@ -104,6 +125,8 @@ class SocketManager {
|
||||
required NetworksMessagingApi client,
|
||||
required String wsUrl,
|
||||
this.onMessageTransform,
|
||||
this.onBeforeReconnect,
|
||||
this.disconnectInBackground = true,
|
||||
this.onCheckNetworkAvailable,
|
||||
this.onLog,
|
||||
}) : _client = client,
|
||||
@@ -124,8 +147,8 @@ class SocketManager {
|
||||
_reconnectOnForeground = false;
|
||||
_reconnectOnNetworkRestore = false;
|
||||
|
||||
// 前置检查:在后台不连接(省电)
|
||||
if (_isInBackground) {
|
||||
// 前置检查:后台断连模式下在后台不连接(省电)
|
||||
if (_isInBackground && disconnectInBackground) {
|
||||
_reconnectOnForeground = true;
|
||||
_log('In background, defer connect to foreground');
|
||||
return false;
|
||||
@@ -165,26 +188,47 @@ class SocketManager {
|
||||
/// 当前是否在后台
|
||||
bool get isInBackground => _isInBackground;
|
||||
|
||||
/// Token 热更新
|
||||
///
|
||||
/// 透传给 SocketClient,仅更新内部 token,不断开连接。
|
||||
/// 适用于 HTTP 层 token 刷新后同步到 WebSocket 的场景。
|
||||
void updateToken(String token) {
|
||||
_lastToken = token;
|
||||
_client.updateToken(token);
|
||||
_log('Token updated via SocketManager');
|
||||
}
|
||||
|
||||
// ── 前后台生命周期 ────────────────────────────────────────────────────────
|
||||
//
|
||||
// 后台 → 断连(省电省流量)
|
||||
// 前台 → 自动重连(如果之前有连接)
|
||||
// 后台 → 保活(心跳不停、连接不断)或断连(省电模式)
|
||||
// 前台 → 检查连接健康 / 自动重连
|
||||
|
||||
/// App 进后台 → 断开连接,标记前台恢复时重连
|
||||
/// App 进后台
|
||||
///
|
||||
/// 由 App 层 WidgetsBindingObserver 在 [AppLifecycleState.paused] 时调用。
|
||||
/// 后台保持连接会消耗电量和流量,断开后由 push 通知兜底。
|
||||
///
|
||||
/// [disconnectInBackground] 为 false 时(后台保活,本项目默认):
|
||||
/// 不断连、不暂停心跳,WebSocket 完全保活。
|
||||
///
|
||||
/// [disconnectInBackground] 为 true 时(后台断连模式):
|
||||
/// 断开连接 + 暂停心跳,由 push 通知兜底,前台恢复时自动重连。
|
||||
void onEnterBackground() {
|
||||
_isInBackground = true;
|
||||
// 取消待执行的前台重连(防止快速 前台→后台 切换导致后台建连)
|
||||
_foregroundReconnectTimer?.cancel();
|
||||
_foregroundReconnectTimer = null;
|
||||
// 同步 SocketClient 内部状态(与 onEnterForeground 对称)
|
||||
|
||||
if (!disconnectInBackground) {
|
||||
// 后台保活模式:不断连、不暂停心跳,不通知 SocketClient
|
||||
_log('Entering background, keeping connection alive');
|
||||
return;
|
||||
}
|
||||
|
||||
// 后台断连模式:通知 SocketClient 进后台(暂停心跳)
|
||||
_client.onEnterBackground();
|
||||
|
||||
if (_lastToken == null) return; // 未登录,无需处理
|
||||
|
||||
// 与 _handleNetworkLost 保持一致:
|
||||
// 不仅 connected,connecting / reconnecting 也要断开,
|
||||
// 防止 SocketClient 在后台继续尝试连接浪费电量和流量。
|
||||
if (_client.isConnected ||
|
||||
@@ -196,18 +240,49 @@ class SocketManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// App 回前台 → 自动重连(如果之前后台断连)
|
||||
/// App 回前台
|
||||
///
|
||||
/// 由 App 层 WidgetsBindingObserver 在 [AppLifecycleState.resumed] 时调用。
|
||||
/// 重连前检查网络可用性,无网络时延迟到网络恢复事件再连。
|
||||
///
|
||||
/// 后台保活模式(disconnectInBackground=false):
|
||||
/// 检查连接健康,如果后台期间连接意外断开则自动重连。
|
||||
///
|
||||
/// 后台断连模式(disconnectInBackground=true):
|
||||
/// 通知 SocketClient 恢复心跳,然后重新建立连接。
|
||||
void onEnterForeground() {
|
||||
_isInBackground = false;
|
||||
|
||||
if (disconnectInBackground) {
|
||||
// 后台断连模式:通知 SocketClient 恢复心跳
|
||||
_client.onEnterForeground();
|
||||
}
|
||||
|
||||
if (!disconnectInBackground && _lastToken != null) {
|
||||
// 后台保活模式:检查连接健康
|
||||
// 虽然后台期间心跳不停,但连接仍可能因网络切换、服务端关闭等原因断开。
|
||||
// SocketClient 的自动重连在后台也会工作(_isBackground=false),
|
||||
// 但回前台时兜底检查一次,确保连接可用。
|
||||
if (!_client.isConnected) {
|
||||
_log('Returning to foreground, connection lost, reconnecting...');
|
||||
_scheduleReconnect();
|
||||
} else {
|
||||
_log('Returning to foreground, connection healthy');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_reconnectOnForeground && _lastToken != null) {
|
||||
// 后台断连模式:之前后台断连过,需要重连
|
||||
_reconnectOnForeground = false;
|
||||
_log('Returning to foreground, reconnecting...');
|
||||
// 延迟 500ms 等待网络稳定,通过 Timer 跟踪以便进后台时取消
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/// 延迟 500ms 后执行重连
|
||||
///
|
||||
/// 等待网络稳定,通过 Timer 跟踪以便进后台时取消。
|
||||
void _scheduleReconnect() {
|
||||
_foregroundReconnectTimer?.cancel();
|
||||
_foregroundReconnectTimer = Timer(
|
||||
const Duration(milliseconds: 500),
|
||||
@@ -226,12 +301,16 @@ class SocketManager {
|
||||
_log('Network unavailable, defer reconnect to network restore');
|
||||
return;
|
||||
}
|
||||
// 重连前钩子:刷新即将过期的 token 等
|
||||
await onBeforeReconnect?.call();
|
||||
// token 可能被 onBeforeReconnect 更新(通过 updateToken 链路同步)
|
||||
if (_lastToken != null && !_client.isConnected) {
|
||||
_client.connect(_wsUrl, token: _lastToken!);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 网络状态变化 ──────────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -275,19 +354,23 @@ class SocketManager {
|
||||
if (_reconnectOnNetworkRestore && _lastToken != null) {
|
||||
_reconnectOnNetworkRestore = false;
|
||||
|
||||
// 在后台不重连,等前台恢复时再连
|
||||
if (_isInBackground) {
|
||||
// 后台断连模式:在后台不重连,等前台恢复时再连
|
||||
if (_isInBackground && disconnectInBackground) {
|
||||
_reconnectOnForeground = true;
|
||||
_log('Network restored but in background, defer to foreground');
|
||||
return;
|
||||
}
|
||||
|
||||
_log('Network restored, scheduling reconnect with backoff');
|
||||
_networkDebouncer.call(() {
|
||||
_networkDebouncer.call(() async {
|
||||
if (!_client.isConnected && _lastToken != null && !_isInBackground) {
|
||||
// 重连前钩子:刷新即将过期的 token 等
|
||||
await onBeforeReconnect?.call();
|
||||
if (!_client.isConnected && _lastToken != null && !_isInBackground) {
|
||||
_log('Backoff timer fired, reconnecting');
|
||||
_client.connect(_wsUrl, token: _lastToken!);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -308,6 +391,9 @@ class SocketManager {
|
||||
/// 原始消息流(不经预处理,调试用)
|
||||
Stream<String> get rawMessageStream => _client.rawMessageStream;
|
||||
|
||||
/// 二进制消息流
|
||||
Stream<dynamic> get binaryMessageStream => _client.binaryMessageStream;
|
||||
|
||||
/// 连接状态变化流
|
||||
Stream<SocketConnectionState> get connectionStateStream =>
|
||||
_client.connectionStateStream;
|
||||
@@ -333,6 +419,14 @@ class SocketManager {
|
||||
return _client.sendString(message);
|
||||
}
|
||||
|
||||
/// 发送二进制数据
|
||||
///
|
||||
/// 前置检查:未连接或在后台时不发送。
|
||||
Future<bool> sendBytes(List<int> bytes) {
|
||||
if (!_canSend()) return Future.value(false);
|
||||
return _client.sendBytes(bytes);
|
||||
}
|
||||
|
||||
// ── 释放 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 释放所有资源
|
||||
@@ -347,7 +441,10 @@ class SocketManager {
|
||||
|
||||
/// 发送前置检查
|
||||
///
|
||||
/// 两重保险:连接状态 + 后台状态。
|
||||
/// 后台保活模式(disconnectInBackground=false):只检查连接状态,
|
||||
/// 后台也能正常发送。
|
||||
///
|
||||
/// 后台断连模式(disconnectInBackground=true):额外检查后台状态,
|
||||
/// 后台已断连所以 isConnected 通常就能拦住,
|
||||
/// 但显式检查 _isInBackground 防止边界情况遗漏。
|
||||
bool _canSend() {
|
||||
@@ -355,8 +452,8 @@ class SocketManager {
|
||||
_log('Not connected, cannot send');
|
||||
return false;
|
||||
}
|
||||
if (_isInBackground) {
|
||||
_log('In background, skip send');
|
||||
if (_isInBackground && disconnectInBackground) {
|
||||
_log('In background (disconnect mode), skip send');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
30
apps/im_app/lib/core/ui/base/assets.dart
Normal file
30
apps/im_app/lib/core/ui/base/assets.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
/// 静态资源路径常量,统一维护,避免路径字符串散落在业务代码中。
|
||||
///
|
||||
/// 所有路径须与 pubspec.yaml 的 flutter.assets 声明保持一致。
|
||||
/// 新增资源:① 文件放入 assets/ ② pubspec.yaml 声明 ③ 此处加常量。
|
||||
///
|
||||
/// 渲染逻辑(缓存、占位、错误态)由 core/ui/components/ 下的组件负责,不在此处封装。
|
||||
///
|
||||
/// ## 使用
|
||||
/// ```dart
|
||||
/// Image.asset(AppAssets.logo)
|
||||
/// Image.asset(AppAssets.logo, width: 80, fit: BoxFit.cover)
|
||||
/// ```
|
||||
abstract final class AppAssets {
|
||||
AppAssets._();
|
||||
|
||||
// ── 品牌 ──────────────────────────────────────────────────
|
||||
static const logo = 'assets/images/logo.png';
|
||||
static const logoLight = 'assets/images/logo_light.png';
|
||||
|
||||
// ── 占位图 ────────────────────────────────────────────────
|
||||
static const avatarPlaceholder = 'assets/images/avatar_placeholder.png';
|
||||
|
||||
// ── 空状态插图(SVG,引入 flutter_svg 后启用) ─────────────
|
||||
// static const emptyChat = 'assets/svg/empty_chat.svg';
|
||||
// static const emptyContact = 'assets/svg/empty_contact.svg';
|
||||
// static const emptySearch = 'assets/svg/empty_search.svg';
|
||||
|
||||
// ── 动画 ──────────────────────────────────────────────────
|
||||
// static const loading = 'assets/gif/loading.gif';
|
||||
}
|
||||
44
apps/im_app/lib/core/ui/base/icons.dart
Normal file
44
apps/im_app/lib/core/ui/base/icons.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 项目图标常量,统一维护,避免 Icons.xxx 散落在业务代码中。
|
||||
///
|
||||
/// 渲染逻辑(大小、颜色、点击态)由调用方负责,不在此处封装。
|
||||
///
|
||||
/// ## 使用
|
||||
/// ```dart
|
||||
/// Icon(AppIcons.send)
|
||||
/// Icon(AppIcons.send, size: 20, color: Colors.white)
|
||||
/// IconButton(icon: Icon(AppIcons.back), onPressed: ...)
|
||||
/// ```
|
||||
abstract final class AppIcons {
|
||||
AppIcons._();
|
||||
|
||||
// ── 底部导航 ──────────────────────────────────────────────
|
||||
static const chat = Icons.chat_bubble_outline_rounded;
|
||||
static const contact = Icons.people_outline_rounded;
|
||||
static const settings = Icons.settings_outlined;
|
||||
|
||||
// ── 通用操作 ──────────────────────────────────────────────
|
||||
static const back = Icons.arrow_back_ios_new_rounded;
|
||||
static const close = Icons.close_rounded;
|
||||
static const more = Icons.more_horiz_rounded;
|
||||
static const search = Icons.search_rounded;
|
||||
static const add = Icons.add_rounded;
|
||||
|
||||
// ── 聊天输入区 ────────────────────────────────────────────
|
||||
static const send = Icons.send_rounded;
|
||||
static const attach = Icons.attach_file_rounded;
|
||||
static const emoji = Icons.emoji_emotions_outlined;
|
||||
static const camera = Icons.camera_alt_outlined;
|
||||
static const voice = Icons.mic_outlined;
|
||||
|
||||
// ── 用户 / 联系人 ─────────────────────────────────────────
|
||||
static const avatar = Icons.account_circle_outlined;
|
||||
static const addUser = Icons.person_add_outlined;
|
||||
|
||||
// ── 状态反馈 ──────────────────────────────────────────────
|
||||
static const success = Icons.check_circle_outline_rounded;
|
||||
static const warning = Icons.warning_amber_rounded;
|
||||
static const error = Icons.error_outline_rounded;
|
||||
static const info = Icons.info_outline_rounded;
|
||||
}
|
||||
@@ -21,9 +21,30 @@ import 'package:im_app/data/local/drift/tables/chats.dart';
|
||||
|
||||
part 'app_database.g.dart';
|
||||
|
||||
@DriftDatabase(tables: [Favourites,Sounds,Tags,PendingFriendRequestHistories,Messages,RecentMiniApps,Retries,Groups,FavoriteMiniApps,DiscoverMiniApps,ChatCategories,ChatBots,FavouriteDetails,UserRequestHistories,Workspaces,Users,ExploreMiniApps,CallLogs,Chats]) //update mapping here
|
||||
@DriftDatabase(
|
||||
tables: [
|
||||
Favourites,
|
||||
Sounds,
|
||||
Tags,
|
||||
PendingFriendRequestHistories,
|
||||
Messages,
|
||||
RecentMiniApps,
|
||||
Retries,
|
||||
Groups,
|
||||
FavoriteMiniApps,
|
||||
DiscoverMiniApps,
|
||||
ChatCategories,
|
||||
ChatBots,
|
||||
FavouriteDetails,
|
||||
UserRequestHistories,
|
||||
Workspaces,
|
||||
Users,
|
||||
ExploreMiniApps,
|
||||
CallLogs,
|
||||
Chats,
|
||||
],
|
||||
) //update mapping here
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
|
||||
static Map<Type, TableInfo> getTableRegistry(GeneratedDatabase database) {
|
||||
if (database is! AppDatabase) {
|
||||
return {};
|
||||
@@ -67,7 +88,9 @@ class AppDatabase extends _$AppDatabase {
|
||||
// Create any new tables that don't exist yet
|
||||
for (final table in allTables) {
|
||||
final existingTables = await m.database
|
||||
.customSelect("SELECT name FROM sqlite_master WHERE type='table' AND name='${table.actualTableName}'")
|
||||
.customSelect(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='${table.actualTableName}'",
|
||||
)
|
||||
.get();
|
||||
|
||||
if (existingTables.isEmpty) {
|
||||
@@ -92,6 +115,4 @@ class AppDatabase extends _$AppDatabase {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -42,7 +42,8 @@ class Chats extends Table {
|
||||
IntColumn get outgoingIdx => integer().withDefault(const Constant(0))();
|
||||
IntColumn get incomingSoundId => integer().withDefault(const Constant(0))();
|
||||
IntColumn get outgoingSoundId => integer().withDefault(const Constant(0))();
|
||||
IntColumn get notificationSoundId => integer().withDefault(const Constant(0))();
|
||||
IntColumn get notificationSoundId =>
|
||||
integer().withDefault(const Constant(0))();
|
||||
TextColumn get chatKey => text().withDefault(const Constant(''))();
|
||||
TextColumn get activeChatKey => text().withDefault(const Constant(''))();
|
||||
IntColumn get coverIdx => integer().withDefault(const Constant(0))();
|
||||
|
||||
@@ -28,9 +28,12 @@ class Users extends Table {
|
||||
IntColumn get addIndex => integer().nullable()();
|
||||
IntColumn get incomingSoundId => integer().withDefault(const Constant(0))();
|
||||
IntColumn get outgoingSoundId => integer().withDefault(const Constant(0))();
|
||||
IntColumn get notificationSoundId => integer().withDefault(const Constant(0))();
|
||||
IntColumn get sendMessageSoundId => integer().withDefault(const Constant(0))();
|
||||
IntColumn get groupNotificationSoundId => integer().withDefault(const Constant(0))();
|
||||
IntColumn get notificationSoundId =>
|
||||
integer().withDefault(const Constant(0))();
|
||||
IntColumn get sendMessageSoundId =>
|
||||
integer().withDefault(const Constant(0))();
|
||||
IntColumn get groupNotificationSoundId =>
|
||||
integer().withDefault(const Constant(0))();
|
||||
TextColumn get groupTags => text().withDefault(const Constant('[]'))();
|
||||
TextColumn get friendTags => text().withDefault(const Constant('[]'))();
|
||||
TextColumn get publicKey => text().nullable()();
|
||||
|
||||
@@ -143,8 +143,7 @@ class ExploreMiniAppDto {
|
||||
screen: screen,
|
||||
);
|
||||
|
||||
factory ExploreMiniAppDto.fromEntity(ExploreMiniApp app) =>
|
||||
ExploreMiniAppDto(
|
||||
factory ExploreMiniAppDto.fromEntity(ExploreMiniApp app) => ExploreMiniAppDto(
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
openuid: app.openuid,
|
||||
|
||||
@@ -49,8 +49,8 @@ class PendingFriendRequestHistoryDto {
|
||||
);
|
||||
|
||||
factory PendingFriendRequestHistoryDto.fromEntity(
|
||||
PendingFriendRequestHistory history) =>
|
||||
PendingFriendRequestHistoryDto(
|
||||
PendingFriendRequestHistory history,
|
||||
) => PendingFriendRequestHistoryDto(
|
||||
id: history.id,
|
||||
uid: history.uid,
|
||||
requestTime: history.requestTime,
|
||||
|
||||
@@ -8,11 +8,7 @@ class UserRequestHistoryDto {
|
||||
final int? status;
|
||||
final int? createdAt;
|
||||
|
||||
const UserRequestHistoryDto({
|
||||
required this.id,
|
||||
this.status,
|
||||
this.createdAt,
|
||||
});
|
||||
const UserRequestHistoryDto({required this.id, this.status, this.createdAt});
|
||||
|
||||
factory UserRequestHistoryDto.fromJson(Map<String, dynamic> json) =>
|
||||
UserRequestHistoryDto(
|
||||
@@ -27,11 +23,8 @@ class UserRequestHistoryDto {
|
||||
'created_at': createdAt,
|
||||
};
|
||||
|
||||
UserRequestHistory toEntity() => UserRequestHistory(
|
||||
id: id,
|
||||
status: status,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
UserRequestHistory toEntity() =>
|
||||
UserRequestHistory(id: id, status: status, createdAt: createdAt);
|
||||
|
||||
factory UserRequestHistoryDto.fromEntity(UserRequestHistory history) =>
|
||||
UserRequestHistoryDto(
|
||||
@@ -40,8 +33,7 @@ class UserRequestHistoryDto {
|
||||
createdAt: history.createdAt,
|
||||
);
|
||||
|
||||
UserRequestHistoriesCompanion toCompanion() =>
|
||||
UserRequestHistoriesCompanion(
|
||||
UserRequestHistoriesCompanion toCompanion() => UserRequestHistoriesCompanion(
|
||||
id: Value(id),
|
||||
status: Value(status),
|
||||
createdAt: Value(createdAt),
|
||||
|
||||
@@ -6,10 +6,10 @@ import '../../../domain/entities/user.dart';
|
||||
|
||||
part 'get_profile_request.g.dart';
|
||||
|
||||
/// # /user/profile — 获取用户资料(GET 请求示例)
|
||||
/// # /user/profile — 获取用户资料(GET 请求)
|
||||
///
|
||||
/// 演示:GET 请求 + 无 body 参数的模式。
|
||||
/// GET 请求的 toJson() 结果会自动作为 URL query parameters 发送。
|
||||
/// GET 请求无 body,`toJson()` 结果自动作为 URL query parameters 发送。
|
||||
/// 如需 query 参数(如分页),直接在类中添加字段,生成器自动序列化。
|
||||
///
|
||||
/// ## 数据流位置
|
||||
///
|
||||
@@ -17,17 +17,19 @@ part 'get_profile_request.g.dart';
|
||||
/// UserRepositoryImpl.getProfile()
|
||||
/// → _client.executeRequest( ★ GetProfileRequest ★ ) ← 你在这里
|
||||
/// → 服务端 GET /user/profile
|
||||
/// → 响应 JSON → ★ ProfileData ★ ← 也在这里
|
||||
/// → ProfileData.toEntity() → User
|
||||
/// → SDK 内部 ApiResponseWrapper 拆包 { code, message, data }
|
||||
/// → ★ ProfileResponse ★ = data 字段 ← 也在这里
|
||||
/// → ProfileResponse.toEntity() → User
|
||||
/// ```
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Response DTO
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/// 用户资料响应 DTO(只需反序列化,禁止生成无用的 toJson)
|
||||
@JsonSerializable(createToJson: false)
|
||||
class ProfileData {
|
||||
/// 用户资料接口的业务响应数据(对应服务端 `data` 字段)。
|
||||
///
|
||||
/// `{ code, message }` 由 SDK 内部的 `ApiResponseWrapper` 统一处理。纯 Dart 类,无需任何注解。
|
||||
class ProfileResponse {
|
||||
final int uid;
|
||||
final String uuid;
|
||||
@JsonKey(name: 'last_online')
|
||||
@@ -54,7 +56,7 @@ class ProfileData {
|
||||
final int channelGroupId;
|
||||
final String hint;
|
||||
|
||||
const ProfileData({
|
||||
const ProfileResponse({
|
||||
required this.uid,
|
||||
required this.uuid,
|
||||
required this.lastOnline,
|
||||
@@ -74,10 +76,6 @@ class ProfileData {
|
||||
required this.hint,
|
||||
});
|
||||
|
||||
factory ProfileData.fromJson(Map<String, dynamic> json) =>
|
||||
_$ProfileDataFromJson(json);
|
||||
|
||||
/// DTO → Domain Entity
|
||||
User toEntity() => User(
|
||||
uid: uid,
|
||||
uuid: uuid,
|
||||
@@ -102,23 +100,12 @@ class ProfileData {
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/// 获取用户资料请求(GET,无参数)
|
||||
///
|
||||
/// GET 请求无 body,toJson() 返回空 map。
|
||||
/// 如需 query 参数(如分页),添加字段即可,
|
||||
/// toJson() 会自动将字段序列化为 URL query string。
|
||||
@ApiRequest(
|
||||
path: ApiPaths.userProfile,
|
||||
method: HttpMethod.get,
|
||||
responseType: ProfileData,
|
||||
responseType: ProfileResponse,
|
||||
)
|
||||
@JsonSerializable()
|
||||
class GetProfileRequest extends ApiRequestable<ProfileData>
|
||||
class GetProfileRequest extends ApiRequestable<ProfileResponse>
|
||||
with _$GetProfileRequestApi {
|
||||
GetProfileRequest();
|
||||
|
||||
factory GetProfileRequest.fromJson(Map<String, dynamic> json) =>
|
||||
_$GetProfileRequestFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$GetProfileRequestToJson(this);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ part 'login_request.g.dart';
|
||||
/// AuthRepositoryImpl.login(email, password)
|
||||
/// → _client.executeRequest( ★ LoginRequest ★ ) ← 你在这里
|
||||
/// → 服务端 POST /auth/login
|
||||
/// → 响应 JSON → ★ LoginResponse ★ ← 也在这里
|
||||
/// → SDK 内部 ApiResponseWrapper 拆包 { code, message, data }
|
||||
/// → ★ LoginResponse ★ = data 字段,T in APIResponseWrapper<T> ← 也在这里
|
||||
/// → LoginResponse.toEntity() → User
|
||||
/// ```
|
||||
|
||||
@@ -22,7 +23,9 @@ part 'login_request.g.dart';
|
||||
// Response DTO
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
@JsonSerializable(createToJson: false)
|
||||
/// 登录响应中的用户档案,嵌套在 [LoginResponse.profile] 中。
|
||||
///
|
||||
/// 纯 Dart 类,无需任何注解。`_$LoginProfileFromJson` 由生成器从 `@ApiRequest` 声明中自动推导生成。
|
||||
class LoginProfile {
|
||||
final int uid;
|
||||
final String uuid;
|
||||
@@ -70,9 +73,6 @@ class LoginProfile {
|
||||
required this.hint,
|
||||
});
|
||||
|
||||
factory LoginProfile.fromJson(Map<String, dynamic> json) =>
|
||||
_$LoginProfileFromJson(json);
|
||||
|
||||
User toEntity() => User(
|
||||
uid: uid,
|
||||
uuid: uuid,
|
||||
@@ -92,8 +92,11 @@ class LoginProfile {
|
||||
);
|
||||
}
|
||||
|
||||
@JsonSerializable(createToJson: false, explicitToJson: true)
|
||||
class LoginData {
|
||||
/// 登录接口的业务响应数据(对应服务端 `data` 字段,即 T in `APIResponseWrapper<T>`)。
|
||||
///
|
||||
/// `{ code, message }` 由 SDK 内部的 `ApiResponseWrapper` 统一处理,
|
||||
/// App 层只接触此类,不感知 envelope 结构。纯 Dart 类,无需任何注解。
|
||||
class LoginResponse {
|
||||
@JsonKey(name: 'account_id')
|
||||
final String accountId;
|
||||
final LoginProfile profile;
|
||||
@@ -107,7 +110,7 @@ class LoginData {
|
||||
@JsonKey(name: 'login_data')
|
||||
final String loginData;
|
||||
|
||||
const LoginData({
|
||||
const LoginResponse({
|
||||
required this.accountId,
|
||||
required this.profile,
|
||||
required this.nonce,
|
||||
@@ -117,52 +120,28 @@ class LoginData {
|
||||
required this.loginData,
|
||||
});
|
||||
|
||||
factory LoginData.fromJson(Map<String, dynamic> json) =>
|
||||
_$LoginDataFromJson(json);
|
||||
|
||||
User toEntity() => profile.toEntity();
|
||||
}
|
||||
|
||||
/// Top-level envelope: { "code": 0, "message": "OK", "data": { ... } }
|
||||
@JsonSerializable(createToJson: false, explicitToJson: true)
|
||||
class LoginResponse {
|
||||
final int code;
|
||||
final String message;
|
||||
final LoginData data;
|
||||
|
||||
const LoginResponse({
|
||||
required this.code,
|
||||
required this.message,
|
||||
required this.data,
|
||||
});
|
||||
|
||||
factory LoginResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$LoginResponseFromJson(json);
|
||||
|
||||
User toEntity() => data.toEntity();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Request
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/// 登录请求
|
||||
///
|
||||
/// `@ApiRequest` 一个注解搞定一切:
|
||||
/// - mixin 自动生成 path / method / requestType / includeToken / toJson
|
||||
/// - parameters getter 自动注册 `_$LoginResponseFromJson` 到 SDK 全局注册表
|
||||
@ApiRequest(
|
||||
path: ApiPaths.authLogin,
|
||||
method: HttpMethod.post,
|
||||
responseType: LoginResponse,
|
||||
requestType: ApiRequestType.login,
|
||||
)
|
||||
@JsonSerializable()
|
||||
class LoginRequest extends ApiRequestable<LoginResponse>
|
||||
with _$LoginRequestApi {
|
||||
final String email;
|
||||
final String password;
|
||||
|
||||
LoginRequest({required this.email, required this.password});
|
||||
|
||||
factory LoginRequest.fromJson(Map<String, dynamic> json) =>
|
||||
_$LoginRequestFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$LoginRequestToJson(this);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@ import 'package:networks_sdk/networks_sdk.dart';
|
||||
|
||||
import '../../../core/foundation/api_paths.dart';
|
||||
|
||||
/// # /auth/logout — 登出接口(无响应数据示例)
|
||||
part 'logout_request.g.dart';
|
||||
|
||||
/// # /auth/logout — 登出接口(无响应数据)
|
||||
///
|
||||
/// 演示:POST 请求 + 无 Response DTO 的模式。
|
||||
/// 服务端返回 `{"code": 0, "message": "ok"}` 无 data 字段,
|
||||
/// `executeRequest` 返回 null,调用方直接 await 即可。
|
||||
///
|
||||
/// 此接口不使用 @ApiRequest 注解,直接实现 ApiRequestable,
|
||||
/// 演示手动实现方式(适用于不需要代码生成器的简单接口)。
|
||||
/// `responseType` 省略 → 生成器跳过 `fromJson` 注册,mixin 泛型为 `void`。
|
||||
///
|
||||
/// ## 数据流位置
|
||||
///
|
||||
@@ -17,16 +17,9 @@ import '../../../core/foundation/api_paths.dart';
|
||||
/// AuthRepositoryImpl.logout()
|
||||
/// → _client.executeRequest( ★ LogoutRequest ★ ) ← 你在这里
|
||||
/// → 服务端 POST /auth/logout
|
||||
/// → 响应 {"code": 0, "message": "ok"} → null
|
||||
/// → 响应 {"code": 0, "message": "ok"} → null(无 data)
|
||||
/// ```
|
||||
class LogoutRequest extends ApiRequestable<void> {
|
||||
@override
|
||||
String get path => ApiPaths.authLogout;
|
||||
|
||||
@override
|
||||
HttpMethod get method => HttpMethod.post;
|
||||
|
||||
/// 登出不需要请求体参数
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {};
|
||||
@ApiRequest(path: ApiPaths.authLogout, method: HttpMethod.post)
|
||||
class LogoutRequest extends ApiRequestable<void> with _$LogoutRequestApi {
|
||||
LogoutRequest();
|
||||
}
|
||||
|
||||
@@ -32,8 +32,7 @@ part 'upload_file_request.g.dart';
|
||||
// Response DTO
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/// 文件上传响应 DTO(只需反序列化,禁止生成无用的 toJson)
|
||||
@JsonSerializable(createToJson: false)
|
||||
/// 文件上传接口的业务响应数据(对应服务端 `data` 字段)。纯 Dart 类,无需任何注解。
|
||||
class UploadResult {
|
||||
final String url;
|
||||
|
||||
@@ -41,9 +40,6 @@ class UploadResult {
|
||||
final String fileId;
|
||||
|
||||
const UploadResult({required this.url, required this.fileId});
|
||||
|
||||
factory UploadResult.fromJson(Map<String, dynamic> json) =>
|
||||
_$UploadResultFromJson(json);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════
|
||||
@@ -52,7 +48,7 @@ class UploadResult {
|
||||
|
||||
/// FormData 上传请求
|
||||
///
|
||||
/// 上传到自有后端 `/upload/file`,响应为标准 `{ code, message, data }` 信封。
|
||||
/// 上传到自有后端 `/upload/file`,响应为标准 `{ code, message, data }` 格式。
|
||||
/// 无需 override `decodeResponse`。
|
||||
@ApiRequest(
|
||||
path: ApiPaths.uploadFile,
|
||||
@@ -97,7 +93,7 @@ class S3UploadResponse {
|
||||
/// - path 为完整的 presigned URL(SDK 检测到 http 开头不拼 baseURL)
|
||||
/// - uploadData 为 Uint8List 二进制数据
|
||||
/// - 自定义 headers(Content-Type: application/octet-stream)
|
||||
/// - override decodeResponse — S3 返回 204 No Content 或 XML,不是标准信封
|
||||
/// - override decodeResponse — S3 返回 204 No Content 或 XML,不是标准响应格式
|
||||
class S3UploadRequest extends ApiRequestable<S3UploadResponse> {
|
||||
final Uint8List data;
|
||||
final String presignedURL;
|
||||
@@ -125,7 +121,7 @@ class S3UploadRequest extends ApiRequestable<S3UploadResponse> {
|
||||
@override
|
||||
Object? get uploadData => data;
|
||||
|
||||
/// S3 响应不走标准 { code, message, data } 信封,需要自定义解码
|
||||
/// S3 响应不走标准 { code, message, data } 格式,需要自定义解码
|
||||
///
|
||||
/// 可能的响应:
|
||||
/// - 204 No Content(空 body)→ 成功
|
||||
|
||||
@@ -8,7 +8,7 @@ import '../remote/logout_request.dart';
|
||||
/// 认证 Repository 实现
|
||||
///
|
||||
/// implements [AuthRepository] 接口(domain/repositories/ 中定义)。
|
||||
/// 直接使用 [ApiClient] 发送请求,将 DTO 转为 Domain Entity。
|
||||
/// 直接使用 [NetworksSdkApi] 发送请求,将 DTO 转为 Domain Entity。
|
||||
/// 后续可加 Local DataSource 实现离线缓存。
|
||||
///
|
||||
/// ## 数据流位置
|
||||
@@ -16,18 +16,22 @@ import '../remote/logout_request.dart';
|
||||
/// ```
|
||||
/// LoginUseCase.execute(email, password)
|
||||
/// → ★ AuthRepositoryImpl.login() ★ ← 你在这里
|
||||
/// → ApiClient.executeRequest(LoginRequest)
|
||||
/// → NetworksSdkApi.executeRequest(LoginRequest)
|
||||
/// → 服务端 POST /auth/login
|
||||
/// ← LoginData(Response DTO)
|
||||
/// → onTokenUpdate(token) ← 回调写入 Token
|
||||
/// ← LoginData.toEntity() → User ← DTO → Entity 转换在这里
|
||||
/// ← LoginResponse(SDK 已拆包 { code, message, data } envelope)
|
||||
/// → _onTokenUpdate(accessToken) ← 回调写入 Token
|
||||
/// ← LoginResponse.toEntity() → User ← DTO → Entity 转换在这里
|
||||
/// ← User(Domain Entity)
|
||||
/// ```
|
||||
class AuthRepositoryImpl implements AuthRepository {
|
||||
final NetworksSdkApi _client;
|
||||
final void Function(String?) _onTokenUpdate;
|
||||
|
||||
AuthRepositoryImpl({required NetworksSdkApi client, required void Function(String?) onTokenUpdate,}) : _client = client, _onTokenUpdate = onTokenUpdate;
|
||||
AuthRepositoryImpl({
|
||||
required NetworksSdkApi client,
|
||||
required void Function(String?) onTokenUpdate,
|
||||
}) : _client = client,
|
||||
_onTokenUpdate = onTokenUpdate;
|
||||
|
||||
@override
|
||||
Future<User> login({required String email, required String password}) async {
|
||||
@@ -39,7 +43,7 @@ class AuthRepositoryImpl implements AuthRepository {
|
||||
throw Exception('Login failed: empty response');
|
||||
}
|
||||
|
||||
_onTokenUpdate(loginResponse.data.accessToken);
|
||||
_onTokenUpdate(loginResponse.accessToken);
|
||||
|
||||
return loginResponse.toEntity();
|
||||
}
|
||||
|
||||
@@ -4,17 +4,9 @@ class UserRequestHistory {
|
||||
final int? status;
|
||||
final int? createdAt;
|
||||
|
||||
const UserRequestHistory({
|
||||
required this.id,
|
||||
this.status,
|
||||
this.createdAt,
|
||||
});
|
||||
const UserRequestHistory({required this.id, this.status, this.createdAt});
|
||||
|
||||
UserRequestHistory copyWith({
|
||||
int? id,
|
||||
int? status,
|
||||
int? createdAt,
|
||||
}) {
|
||||
UserRequestHistory copyWith({int? id, int? status, int? createdAt}) {
|
||||
return UserRequestHistory(
|
||||
id: id ?? this.id,
|
||||
status: status ?? this.status,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// 数据库测试页状态(Demo,正式开发后随页面一并删除)
|
||||
|
||||
/// 单条测试结果记录
|
||||
class TestResult {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String duration;
|
||||
|
||||
TestResult({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.duration,
|
||||
});
|
||||
}
|
||||
|
||||
class ChatDbTestState {
|
||||
final bool testStarted;
|
||||
final List<TestResult> testResults;
|
||||
final String currentState;
|
||||
|
||||
const ChatDbTestState({
|
||||
this.testStarted = false,
|
||||
this.testResults = const [],
|
||||
this.currentState = '',
|
||||
});
|
||||
|
||||
/// 按钮文案(Widget 直接读,不在 View 层做判断)
|
||||
String get buttonLabel => testStarted ? '结束' : '开始';
|
||||
|
||||
ChatDbTestState copyWith({
|
||||
bool? testStarted,
|
||||
List<TestResult>? testResults,
|
||||
String? currentState,
|
||||
}) => ChatDbTestState(
|
||||
testStarted: testStarted ?? this.testStarted,
|
||||
testResults: testResults ?? this.testResults,
|
||||
currentState: currentState ?? this.currentState,
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/ui/base/context_theme_ext.dart';
|
||||
|
||||
@@ -12,7 +13,7 @@ import '../../../../core/ui/base/context_theme_ext.dart';
|
||||
///
|
||||
/// 将 [conversationId] 传给对应的 Riverpod `.family` provider 加载完整会话数据。
|
||||
/// 构造参数保持不变,数据来源从 `extra` 换成 provider 即可。
|
||||
class ChatDetailPage extends StatelessWidget {
|
||||
class ChatDetailPage extends ConsumerWidget {
|
||||
const ChatDetailPage({
|
||||
super.key,
|
||||
required this.conversationId,
|
||||
@@ -23,7 +24,7 @@ class ChatDetailPage extends StatelessWidget {
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final s = context.styles;
|
||||
|
||||
return Scaffold(
|
||||
|
||||
@@ -20,8 +20,6 @@ class ChatPage extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final vm = ref.read(chatViewModelProvider.notifier);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('聊天')),
|
||||
body: Center(
|
||||
@@ -32,36 +30,48 @@ class ChatPage extends ConsumerWidget {
|
||||
// 切换 Tab:用 go,替换整个历史栈,不可返回
|
||||
AppButton.inverse(
|
||||
label: '切换 Tab(go)',
|
||||
onPressed: () => vm.goToContact(context),
|
||||
onPressed: () =>
|
||||
ref.read(chatViewModelProvider.notifier).goToContact(context),
|
||||
),
|
||||
// 带参数 push:extra 传 Dart Record,适合已有对象的场景
|
||||
AppButton.inverse(
|
||||
label: '有参 push(extra)',
|
||||
onPressed: () => vm.pushChatDetailWithExtra(context),
|
||||
onPressed: () => ref
|
||||
.read(chatViewModelProvider.notifier)
|
||||
.pushChatDetailWithExtra(context),
|
||||
),
|
||||
// 带参数 push:id 内嵌在路径中,适合需要深链接 / 分享的场景
|
||||
AppButton.inverse(
|
||||
label: '有参 push(路径参数)',
|
||||
onPressed: () => vm.pushChatDetailById(context),
|
||||
onPressed: () => ref
|
||||
.read(chatViewModelProvider.notifier)
|
||||
.pushChatDetailById(context),
|
||||
),
|
||||
// 无参 push:压栈,自动显示返回按钮,不切 Tab
|
||||
AppButton.inverse(
|
||||
label: '无参 push',
|
||||
onPressed: () => vm.pushSettingsTheme(context),
|
||||
onPressed: () => ref
|
||||
.read(chatViewModelProvider.notifier)
|
||||
.pushSettingsTheme(context),
|
||||
),
|
||||
// 无参 go:替换历史,切换到对应 Tab,TabBar 可见,不可返回
|
||||
AppButton.inverse(
|
||||
label: '无参 go',
|
||||
onPressed: () => vm.goToSettings(context),
|
||||
onPressed: () => ref
|
||||
.read(chatViewModelProvider.notifier)
|
||||
.goToSettings(context),
|
||||
),
|
||||
AppButton.inverse(
|
||||
label: '测试数据库性能',
|
||||
onPressed: () => vm.goToDatabaseTest(context),
|
||||
onPressed: () => ref
|
||||
.read(chatViewModelProvider.notifier)
|
||||
.goToDatabaseTest(context),
|
||||
),
|
||||
AppButton.secondary(
|
||||
label: '退出登录',
|
||||
fullWidth: false,
|
||||
onPressed: () => vm.logout(),
|
||||
onPressed: () =>
|
||||
ref.read(chatViewModelProvider.notifier).logout(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 联系人页占位
|
||||
///
|
||||
/// 待 contact 功能开发后替换为实际内容。
|
||||
class ContactPage extends StatelessWidget {
|
||||
class ContactPage extends ConsumerWidget {
|
||||
const ContactPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return const Scaffold();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import '../usecases/login_usecase.dart';
|
||||
/// ViewModel Provider 由 `@riverpod` 注解自动生成,不在此文件中。
|
||||
///
|
||||
/// Auth 模块的 DI 链路:Repository → UseCase(按需)。
|
||||
/// app/di/ 只提供 SDK 基础设施(apiConfig / apiClient / socketManager / storageApi),
|
||||
/// app/di/ 只提供 SDK 基础设施(apiConfig / networkSdkApi / socketManager / storageApi),
|
||||
/// 业务模块的 Provider 内聚在 features/{模块}/di/ 下。
|
||||
///
|
||||
/// ```
|
||||
@@ -21,7 +21,7 @@ import '../usecases/login_usecase.dart';
|
||||
/// → ref.read(authRepositoryProvider) ← di/ 手动装配
|
||||
/// → ref.read(socketManagerProvider) ← app/di/ 手动装配
|
||||
/// → ref.read(apiConfigProvider) ← app/di/ 手动装配
|
||||
/// → ref.read(apiClientProvider) ← app/di/ 手动装配
|
||||
/// → ref.read(networkSdkApiProvider) ← app/di/ 手动装配
|
||||
/// → ref.read(storageSdkProvider) ← app/di/ 手动装配
|
||||
/// ```
|
||||
|
||||
@@ -41,7 +41,7 @@ final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||
// TODO: final secureStorage = ref.read(secureStorageProvider);
|
||||
|
||||
return AuthRepositoryImpl(
|
||||
client: ref.read(networkSdkApiProvider), // 直接注入 ApiClient
|
||||
client: ref.read(networkSdkApiProvider), // 注入 Facade 接口
|
||||
onTokenUpdate: (token) {
|
||||
apiConfig.updateToken(token); // 内存(network_sdk)
|
||||
// TODO: secureStorage.saveToken(token); // 持久化(crypto_sdk)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:im_app/app/di/user_provider.dart';
|
||||
import 'package:im_app/data/remote/login_request.dart';
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
import 'package:im_app/app/di/db_provider.dart';
|
||||
import 'package:im_app/app/di/user_provider.dart';
|
||||
import 'package:im_app/domain/entities/user.dart';
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:storage_sdk/storage_sdk.dart';
|
||||
|
||||
@@ -33,7 +33,7 @@ part 'login_view_model.g.dart';
|
||||
/// loginViewModelProvider ← @riverpod 自动生成(本文件)
|
||||
/// → ref.read(loginUseCaseProvider) ← di/ 手动装配
|
||||
/// → ref.read(authRepositoryProvider) ← di/ 手动装配
|
||||
/// → ref.read(apiClientProvider) ← app/di/ 手动装配
|
||||
/// → ref.read(networkSdkApiProvider) ← app/di/ 手动装配
|
||||
/// ```
|
||||
///
|
||||
/// ## 数据流位置
|
||||
@@ -44,7 +44,7 @@ part 'login_view_model.g.dart';
|
||||
/// → LoginUseCase.execute() ← 格式校验 + 调 Repository
|
||||
/// → AuthRepository.login()
|
||||
/// → _client.executeRequest(LoginRequest)
|
||||
/// ← LoginData → User
|
||||
/// ← LoginResponse → User
|
||||
/// ← User
|
||||
/// → state = state.copyWith(user: user) ← 更新状态
|
||||
/// View: ref.watch → 自动 rebuild ← UI 刷新
|
||||
@@ -59,27 +59,52 @@ class LoginViewModel extends _$LoginViewModel {
|
||||
/// 仅用于演示路由守卫行为,正式 UI 就绪后删除此方法,改用 [login]。
|
||||
/// 正式 [login] 成功后同样需要调用 [AuthNotifier.login] 更新守卫状态。
|
||||
Future<void> demoLogin() async {
|
||||
// 防止连点重入:第一次调用未完成前忽略后续调用
|
||||
if (state.isLoading) return;
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
final storageApi = ref.read(storageSdkProvider);
|
||||
final storageLifeCycle = storageApi as StorageSdkLifecycle;
|
||||
final repositoryProvider = ref.read(userRepositoryProvider);
|
||||
final provider = ref.read(authNotifierProvider);
|
||||
|
||||
// Read mock response from assets
|
||||
final String raw = await rootBundle.loadString('assets/loginData.json');
|
||||
final Map<String, dynamic> json = jsonDecode(raw);
|
||||
try {
|
||||
// 读取 mock 数据(loginData.json 结构: { code, message, data: {...} })
|
||||
// 手动拆包 data 字段,对应 SDK 内部 ApiResponseWrapper 的行为
|
||||
final raw = await rootBundle.loadString('assets/loginData.json');
|
||||
final json = jsonDecode(raw) as Map<String, dynamic>;
|
||||
final data = json['data'] as Map<String, dynamic>;
|
||||
final profile = data['profile'] as Map<String, dynamic>;
|
||||
// 生成器生成的 _$XFromJson 是 library 私有函数,外部不可调用。
|
||||
// Demo 场景直接从 JSON 字段构建 User,不依赖生成的 fromJson。
|
||||
final user = User(
|
||||
uid: profile['uid'] as int,
|
||||
uuid: profile['uuid'] as String,
|
||||
lastOnline: profile['last_online'] as int,
|
||||
profilePic: profile['profile_pic'] as String,
|
||||
profilePicGaussian: profile['profile_pic_gaussian'] as String,
|
||||
nickname: profile['nickname'] as String,
|
||||
contact: profile['contact'] as String,
|
||||
countryCode: profile['country_code'] as String,
|
||||
email: profile['email'] as String,
|
||||
recoveryEmail: profile['recovery_email'] as String,
|
||||
username: profile['username'] as String,
|
||||
bio: profile['bio'] as String,
|
||||
relationship: profile['relationship'] as int,
|
||||
userAlias: profile['user_alias'] as String?,
|
||||
hint: profile['hint'] as String,
|
||||
);
|
||||
|
||||
// Parse → Domain User directly
|
||||
final loginResponse = LoginResponse.fromJson(json);
|
||||
final user = loginResponse.data.toEntity();
|
||||
|
||||
// Open database for the user
|
||||
// 先完成 DB 操作,再标记登录状态(失败时不会误标为已登录)
|
||||
await storageLifeCycle.openDatabase(user.uid);
|
||||
|
||||
// Save user to DB via repository
|
||||
await repositoryProvider.saveUser(user);
|
||||
|
||||
// Trigger auth state
|
||||
provider.login();
|
||||
} catch (e) {
|
||||
// 导航已发生时 provider 已被 dispose,静默丢弃,不再写 state
|
||||
state = state.copyWith(error: e.toString(), isLoading: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行登录
|
||||
@@ -89,12 +114,10 @@ class LoginViewModel extends _$LoginViewModel {
|
||||
/// 3. 成功:写入 user;失败:写入 error
|
||||
Future<void> login(String email, String password) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
final provider = ref.read(loginUseCaseProvider);
|
||||
|
||||
try {
|
||||
final user = await ref
|
||||
.read(loginUseCaseProvider)
|
||||
.execute(email: email, password: password);
|
||||
|
||||
final user = await provider.execute(email: email, password: password);
|
||||
state = state.copyWith(user: user, isLoading: false);
|
||||
} on FormatException catch (e) {
|
||||
// 格式校验失败(UseCase 层抛出)
|
||||
|
||||
@@ -28,9 +28,9 @@ import '../../../domain/repositories/auth_repository.dart';
|
||||
/// → AuthRepository.login()
|
||||
/// → AuthRepositoryImpl.login()
|
||||
/// → _client.executeRequest(LoginRequest)
|
||||
/// ← LoginData(DTO)
|
||||
/// → _onTokenUpdate(token) ← 回调写入 Token(内存 + 持久化,由 Provider 层组合)
|
||||
/// ← LoginData.toEntity() → User
|
||||
/// ← LoginResponse(SDK 已拆包 envelope)
|
||||
/// → _onTokenUpdate(accessToken) ← 回调写入 Token(内存 + 持久化,由 Provider 层组合)
|
||||
/// ← LoginResponse.toEntity() → User
|
||||
/// → SocketManager.connect(token) ← 登录后连接 WebSocket
|
||||
/// → StorageSdkApi.openDatabase(user.id) ← 按用户 id 打开本地库
|
||||
/// ← User
|
||||
@@ -41,7 +41,8 @@ class LoginUseCase {
|
||||
final ApiConfig _apiConfig;
|
||||
final StorageSdkApi _storageApi;
|
||||
|
||||
StorageSdkLifecycle get _storageLifeCycle => _storageApi as StorageSdkLifecycle;
|
||||
StorageSdkLifecycle get _storageLifeCycle =>
|
||||
_storageApi as StorageSdkLifecycle;
|
||||
|
||||
LoginUseCase({
|
||||
required AuthRepository authRepository,
|
||||
@@ -72,10 +73,7 @@ class LoginUseCase {
|
||||
_validatePassword(password);
|
||||
|
||||
// ── 2. 登录 ──
|
||||
final user = await _authRepository.login(
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
final user = await _authRepository.login(email: email, password: password);
|
||||
|
||||
// ── 3. 连接 WebSocket ──
|
||||
// token 在 Repository 的 _onTokenUpdate 回调中已写入 ApiConfig,
|
||||
|
||||
@@ -15,13 +15,12 @@ class LoginPage extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// ref.watch 保持 loginViewModelProvider 存活(AutoDispose 需要至少一个监听者)
|
||||
final state = ref.watch(loginViewModelProvider);
|
||||
final s = context.styles;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('登录'),
|
||||
automaticallyImplyLeading: false,
|
||||
),
|
||||
appBar: AppBar(title: const Text('登录'), automaticallyImplyLeading: false),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -34,7 +33,9 @@ class LoginPage extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
FilledButton(
|
||||
onPressed: () => ref.read(loginViewModelProvider.notifier).demoLogin(),
|
||||
onPressed: state.isLoading
|
||||
? null
|
||||
: () => ref.read(loginViewModelProvider.notifier).demoLogin(),
|
||||
child: const Text('登录'),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -16,32 +16,27 @@ class ThemeView extends ConsumerWidget {
|
||||
final current = ref.watch(themeViewModelProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('主题'),
|
||||
),
|
||||
appBar: AppBar(title: const Text('主题')),
|
||||
body: ListView(
|
||||
children: [
|
||||
const SettingsSectionHeader(title: '外观'),
|
||||
ThemeOptionTile(
|
||||
label: '跟随系统',
|
||||
mode: ThemeMode.system,
|
||||
current: current,
|
||||
isSelected: current == ThemeMode.system,
|
||||
onTap: () => ref
|
||||
.read(themeViewModelProvider.notifier)
|
||||
.setMode(ThemeMode.system),
|
||||
),
|
||||
ThemeOptionTile(
|
||||
label: '黑色模式',
|
||||
mode: ThemeMode.dark,
|
||||
current: current,
|
||||
isSelected: current == ThemeMode.dark,
|
||||
onTap: () => ref
|
||||
.read(themeViewModelProvider.notifier)
|
||||
.setMode(ThemeMode.dark),
|
||||
),
|
||||
ThemeOptionTile(
|
||||
label: '白色模式',
|
||||
mode: ThemeMode.light,
|
||||
current: current,
|
||||
isSelected: current == ThemeMode.light,
|
||||
onTap: () => ref
|
||||
.read(themeViewModelProvider.notifier)
|
||||
.setMode(ThemeMode.light),
|
||||
|
||||
@@ -5,14 +5,13 @@ import '../../../../../core/ui/base/context_theme_ext.dart';
|
||||
/// 单个主题选项行
|
||||
///
|
||||
/// 纯展示 + 事件透传,不感知任何 Provider。
|
||||
/// 由父级传入 [current] 判断选中状态,[onTap] 处理切换。
|
||||
/// 父级传入 [isSelected] 决定是否显示勾选图标,[onTap] 处理切换。
|
||||
///
|
||||
/// 用法:
|
||||
/// ```dart
|
||||
/// ThemeOptionTile(
|
||||
/// label: '黑色模式',
|
||||
/// mode: ThemeMode.dark,
|
||||
/// current: current,
|
||||
/// isSelected: current == ThemeMode.dark,
|
||||
/// onTap: () => ref.read(themeViewModelProvider.notifier).setMode(ThemeMode.dark),
|
||||
/// )
|
||||
/// ```
|
||||
@@ -20,20 +19,17 @@ class ThemeOptionTile extends StatelessWidget {
|
||||
const ThemeOptionTile({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.mode,
|
||||
required this.current,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final ThemeMode mode;
|
||||
final ThemeMode current;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = context.styles;
|
||||
final isSelected = current == mode;
|
||||
|
||||
return ListTile(
|
||||
title: Text(label),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
platform :osx, '11.0'
|
||||
platform :osx, '14.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
@@ -38,5 +38,8 @@ end
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_macos_build_settings(target)
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['SWIFT_VERSION'] ||= '6.2'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -481,7 +481,7 @@
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.2;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/im_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/im_app";
|
||||
};
|
||||
name = Debug;
|
||||
@@ -496,7 +496,7 @@
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.2;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/im_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/im_app";
|
||||
};
|
||||
name = Release;
|
||||
@@ -511,7 +511,7 @@
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.2;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/im_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/im_app";
|
||||
};
|
||||
name = Profile;
|
||||
@@ -557,7 +557,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
@@ -580,7 +580,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.2;
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
@@ -639,7 +639,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
@@ -689,7 +689,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
@@ -713,7 +713,7 @@
|
||||
);
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.2;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@@ -732,7 +732,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.2;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
|
||||
@@ -37,9 +37,13 @@ dependencies:
|
||||
# 网络状态监听
|
||||
connectivity_plus: ^6.1.0
|
||||
|
||||
# JWT 解析(token 过期检测、主动刷新)
|
||||
dart_jsonwebtoken: ^3.3.2
|
||||
|
||||
# 数据库(schema 定义在 im_app,连接/CRUD 封装在 storage_sdk)
|
||||
drift: ^2.22.0
|
||||
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
group = "com.example.cipher_guard_sdk"
|
||||
version = "1.0-SNAPSHOT"
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = "2.2.20"
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.11.1")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: "com.android.library"
|
||||
apply plugin: "kotlin-android"
|
||||
|
||||
android {
|
||||
namespace = "com.example.cipher_guard_sdk"
|
||||
|
||||
compileSdk = 36
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += "src/main/kotlin"
|
||||
test.java.srcDirs += "src/test/kotlin"
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
||||
testImplementation("org.mockito:mockito-core:5.0.0")
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.all {
|
||||
useJUnitPlatform()
|
||||
|
||||
testLogging {
|
||||
events "passed", "skipped", "failed", "standardOut", "standardError"
|
||||
outputs.upToDateWhen {false}
|
||||
showStandardStreams = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
71
packages/cipher_guard_sdk/android/build.gradle.kts
Normal file
71
packages/cipher_guard_sdk/android/build.gradle.kts
Normal file
@@ -0,0 +1,71 @@
|
||||
group = "com.example.cipher_guard_sdk"
|
||||
version = "1.0-SNAPSHOT"
|
||||
|
||||
buildscript {
|
||||
val kotlinVersion = "2.2.20"
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.11.1")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.cipher_guard_sdk"
|
||||
compileSdk = 36
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") { java.srcDirs("src/main/kotlin") }
|
||||
getByName("test") { java.srcDirs("src/test/kotlin") }
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
all {
|
||||
it.useJUnitPlatform()
|
||||
it.outputs.upToDateWhen { false }
|
||||
it.testLogging {
|
||||
events("passed", "skipped", "failed", "standardOut", "standardError")
|
||||
showStandardStreams = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Compose BOM 统一管理子库版本,按需在各 SDK 中引入具体组件
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
||||
testImplementation("org.mockito:mockito-core:5.0.0")
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import Flutter
|
||||
@preconcurrency import Flutter
|
||||
import UIKit
|
||||
|
||||
public class CipherGuardSdkPlugin: NSObject, FlutterPlugin {
|
||||
|
||||
@@ -19,7 +19,7 @@ A new Flutter plugin project.
|
||||
|
||||
# Flutter.framework does not contain a i386 slice.
|
||||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
|
||||
s.swift_version = '5.0'
|
||||
s.swift_version = '6.2'
|
||||
|
||||
# If your plugin requires a privacy manifest, for example if it uses any
|
||||
# required reason APIs, update the PrivacyInfo.xcprivacy file to describe your
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
library;
|
||||
|
||||
export 'src/presentation/facade/cipher_guard_sdk_api.dart';
|
||||
export 'src/data/datasources/encryption_flutter_service.dart' show KdfMode;
|
||||
export 'src/domain/entities/rsa_key_pair.dart';
|
||||
export 'src/domain/entities/session_key.dart';
|
||||
export 'src/domain/entities/encrypted_message.dart';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:isolate';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
@@ -7,42 +8,141 @@ import 'package:crypto/crypto.dart';
|
||||
import 'package:encrypt/encrypt.dart' as encrypt_pkg;
|
||||
import 'package:pointycastle/api.dart';
|
||||
import 'package:pointycastle/asymmetric/api.dart';
|
||||
import 'package:pointycastle/asymmetric/pkcs1.dart';
|
||||
import 'package:pointycastle/asymmetric/rsa.dart';
|
||||
import 'package:pointycastle/digests/sha256.dart';
|
||||
import 'package:pointycastle/key_derivators/api.dart';
|
||||
import 'package:pointycastle/key_derivators/pbkdf2.dart';
|
||||
import 'package:pointycastle/key_generators/api.dart';
|
||||
import 'package:pointycastle/key_generators/rsa_key_generator.dart';
|
||||
import 'package:pointycastle/macs/hmac.dart';
|
||||
import 'package:pointycastle/random/fortuna_random.dart';
|
||||
import 'package:pointycastle/asymmetric/rsa.dart';
|
||||
import 'package:pointycastle/asymmetric/pkcs1.dart';
|
||||
|
||||
/// Flutter Encryption Service
|
||||
/// Implements all encryption logic in Flutter using pointycastle and encrypt packages
|
||||
/// Replaces native Android/iOS encryption implementations
|
||||
/// 密钥派生模式
|
||||
///
|
||||
/// 决定 [EncryptionFlutterService._deriveKeyForRound] 使用哪种算法。
|
||||
/// 默认 [md5],可选 [pbkdf2](增强安全性)。
|
||||
///
|
||||
/// 解密旧数据时必须使用加密时相同的模式,
|
||||
/// 通过消息的 version 字段区分。
|
||||
enum KdfMode {
|
||||
/// MD5 简单哈希(默认模式)
|
||||
///
|
||||
/// 适用于 session key 已是 32 字节强随机值的场景。
|
||||
/// 性能好,每次调用 < 0.1ms。
|
||||
md5,
|
||||
|
||||
/// PBKDF2-HMAC-SHA256(可选增强模式)
|
||||
///
|
||||
/// 适用于从弱密码派生密钥的场景。
|
||||
/// 性能取决于迭代次数,10000 次约 10-50ms。
|
||||
pbkdf2,
|
||||
}
|
||||
|
||||
/// Flutter 加密服务
|
||||
///
|
||||
/// 端对端加密的核心引擎,纯 Dart 实现。
|
||||
/// 使用 pointycastle(RSA)+ encrypt(AES)+ crypto(MD5)。
|
||||
///
|
||||
/// ## 性能优化
|
||||
///
|
||||
/// - **RSA 密钥生成**:通过 [generateRsaKeyPairAsync] 在 Isolate 中运行,
|
||||
/// 避免阻塞主线程(1024-bit 约 150ms,2048-bit 约 300ms)
|
||||
/// - **RSA 解析缓存**:[_parsePublicKey] / [_parsePrivateKey] 缓存 ASN1 解析结果,
|
||||
/// 同一密钥 PEM 只做一次 BigInt 构造,后续命中缓存(LRU,上限 8 条)
|
||||
/// - **Session key bytes 缓存**:[_getSessionKeyBytes] 缓存 base64 → Uint8List 结果,
|
||||
/// 同一 session 的多条消息只解码一次(LRU,上限 64 条)
|
||||
/// - **派生密钥缓存**:[_deriveKeyForRound] 结果按 (sessionKey, round, mode) 缓存,
|
||||
/// 同一 session + round 的重复加解密直接命中(LRU,上限 64 条)
|
||||
/// - **Random.secure() 复用**:全局单例,不再每次调用创建新实例
|
||||
/// - **KDF 双模式**:MD5(默认)/ PBKDF2(可选,增强安全性)
|
||||
///
|
||||
/// ## 正确的接入姿势(避免重复读文件)
|
||||
///
|
||||
/// 调用方(App 层)在登录后调一次 [CipherGuardSdkApi.setActiveKeyPair],
|
||||
/// 把从安全存储读出的公私钥注入 SDK 内存。后续加解密使用
|
||||
/// [CipherGuardSdkApi.encryptSessionKeyWithActiveKey] /
|
||||
/// [CipherGuardSdkApi.decryptSessionKeyWithActiveKey],
|
||||
/// 不再每次传 key 参数,也不再重复读文件。
|
||||
class EncryptionFlutterService {
|
||||
// ==================== Constants ====================
|
||||
// ==================== 配置 ====================
|
||||
|
||||
/// 密钥派生模式,默认 MD5
|
||||
final KdfMode kdfMode;
|
||||
|
||||
/// PBKDF2 迭代次数(仅 PBKDF2 模式有效,默认 10000)
|
||||
final int pbkdf2Iterations;
|
||||
|
||||
EncryptionFlutterService({
|
||||
this.kdfMode = KdfMode.md5,
|
||||
this.pbkdf2Iterations = 10000,
|
||||
});
|
||||
|
||||
// ==================== 常量 ====================
|
||||
|
||||
static const int sessionKeySize = 32;
|
||||
static const int gcmIvLength = 12;
|
||||
static const int _maxDerivedKeyCacheSize = 64;
|
||||
static const int _maxRsaKeyCacheSize = 8;
|
||||
static const int _maxSessionKeyBytesCacheSize = 64;
|
||||
|
||||
// ==================== RSA Key Management ====================
|
||||
// ==================== 性能优化:复用 Random 实例 ====================
|
||||
|
||||
/// Generate RSA key pair in PEM format
|
||||
/// 全局 Random.secure() 单例,避免每次调用创建新实例
|
||||
static final Random _secureRandom = Random.secure();
|
||||
|
||||
// ==================== 性能优化:派生密钥 LRU 缓存 ====================
|
||||
|
||||
/// 派生密钥缓存:'sessionKey:round:mode' -> Uint8List
|
||||
///
|
||||
/// 同一 session + round 的加解密只派生一次,后续直接命中缓存。
|
||||
/// LinkedHashMap 保持插入顺序,满时淘汰最早条目。
|
||||
final _derivedKeyCache = <String, Uint8List>{};
|
||||
|
||||
/// 清空派生密钥缓存(session key 轮换时调用)
|
||||
void clearDerivedKeyCache() => _derivedKeyCache.clear();
|
||||
|
||||
// ==================== 性能优化:RSA 解析缓存 ====================
|
||||
|
||||
/// RSA 公钥解析缓存:PEM -> RSAPublicKey
|
||||
///
|
||||
/// RSA 密钥生命周期长(通常每设备一对),ASN1 解析 + BigInt 构造代价较高。
|
||||
/// 解析结果在内存中复用,省去重复解析开销。上限 8 条,满时淘汰最早。
|
||||
final _rsaPublicKeyCache = <String, RSAPublicKey>{};
|
||||
|
||||
/// RSA 私钥解析缓存:PEM -> RSAPrivateKey
|
||||
final _rsaPrivateKeyCache = <String, RSAPrivateKey>{};
|
||||
|
||||
// ==================== 性能优化:session key bytes 缓存 ====================
|
||||
|
||||
/// Session key Base64 → 字节缓存
|
||||
///
|
||||
/// _deriveKeyForRound 和 _pbkdf2Derive 每次都需要 base64Decode(sessionKey),
|
||||
/// 对同一会话的多条消息重复解码。缓存后只解码一次,满时淘汰最早。
|
||||
final _sessionKeyBytesCache = <String, Uint8List>{};
|
||||
|
||||
// ==================== RSA 密钥管理 ====================
|
||||
|
||||
/// 生成 RSA 密钥对(同步,阻塞主线程)
|
||||
///
|
||||
/// 建议使用 [generateRsaKeyPairAsync] 代替,避免 UI 卡顿。
|
||||
RsaKeyPairResult generateRsaKeyPair({int keySize = 1024}) {
|
||||
try {
|
||||
// Get secure random
|
||||
final secureRandom = FortunaRandom();
|
||||
secureRandom.seed(KeyParameter(_generateSecureRandomBytes(32)));
|
||||
|
||||
// Create RSA key generator
|
||||
final keyGen = RSAKeyGenerator();
|
||||
keyGen.init(ParametersWithRandom(
|
||||
keyGen.init(
|
||||
ParametersWithRandom(
|
||||
RSAKeyGeneratorParameters(BigInt.parse('65537'), keySize, 64),
|
||||
secureRandom,
|
||||
));
|
||||
),
|
||||
);
|
||||
|
||||
// Generate key pair
|
||||
final keyPair = keyGen.generateKeyPair();
|
||||
final rsaPublicKey = keyPair.publicKey as RSAPublicKey;
|
||||
final rsaPrivateKey = keyPair.privateKey as RSAPrivateKey;
|
||||
final rsaPublicKey = keyPair.publicKey;
|
||||
final rsaPrivateKey = keyPair.privateKey;
|
||||
|
||||
// Export to PEM format
|
||||
final publicKeyPem = _encodeRSAPublicKey(rsaPublicKey);
|
||||
final privateKeyPem = _encodeRSAPrivateKey(rsaPrivateKey);
|
||||
|
||||
@@ -55,26 +155,38 @@ class EncryptionFlutterService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode RSA public key to PEM format using asn1lib
|
||||
/// 生成 RSA 密钥对(异步,在 Isolate 中运行,不阻塞主线程)
|
||||
///
|
||||
/// RSA 密钥生成是 CPU 密集型操作(1024-bit 约 150ms,2048-bit 约 300ms),
|
||||
/// 放在 Isolate 中避免主线程卡顿。
|
||||
///
|
||||
/// **Isolate 隔离说明**:
|
||||
/// Isolate 内会创建一个**默认配置**的 EncryptionFlutterService(KdfMode.md5),
|
||||
/// 不会继承当前实例的 kdfMode / pbkdf2Iterations。
|
||||
/// 这对 RSA 密钥生成没有影响(RSA 不走 KDF),但如果将来需要在
|
||||
/// Isolate 中执行依赖 KDF 的操作(如消息加解密),需要传递配置参数。
|
||||
Future<RsaKeyPairResult> generateRsaKeyPairAsync({int keySize = 1024}) async {
|
||||
return await Isolate.run(
|
||||
() => EncryptionFlutterService().generateRsaKeyPair(keySize: keySize),
|
||||
);
|
||||
}
|
||||
|
||||
/// 编码 RSA 公钥为 PEM 格式
|
||||
String _encodeRSAPublicKey(RSAPublicKey publicKey) {
|
||||
// Build RSAPublicKeyInfo structure
|
||||
final topSeq = ASN1Sequence();
|
||||
|
||||
// AlgorithmIdentifier: OID 1.2.840.113549.1.1.1 + NULL
|
||||
final algoSeq = ASN1Sequence();
|
||||
algoSeq.add(ASN1ObjectIdentifier([1, 2, 840, 113549, 1, 1, 1])); // RSA
|
||||
algoSeq.add(ASN1ObjectIdentifier([1, 2, 840, 113549, 1, 1, 1]));
|
||||
algoSeq.add(ASN1Null());
|
||||
topSeq.add(algoSeq);
|
||||
|
||||
// RSAPublicKey: modulus + publicExponent
|
||||
final keySeq = ASN1Sequence();
|
||||
keySeq.add(ASN1Integer(publicKey.n!));
|
||||
keySeq.add(ASN1Integer(publicKey.exponent!));
|
||||
|
||||
// BitString wrapping the key (with 0 unused bits prefix)
|
||||
final keyBytes = keySeq.encodedBytes;
|
||||
final keyList = List<int>.from(keyBytes);
|
||||
keyList.insert(0, 0); // Add unused bits byte
|
||||
keyList.insert(0, 0);
|
||||
topSeq.add(ASN1BitString(keyList));
|
||||
|
||||
final derBytes = topSeq.encodedBytes;
|
||||
@@ -82,51 +194,32 @@ class EncryptionFlutterService {
|
||||
return '-----BEGIN PUBLIC KEY-----\n$base64\n-----END PUBLIC KEY-----';
|
||||
}
|
||||
|
||||
/// Encode RSA private key to PEM format using asn1lib
|
||||
/// 编码 RSA 私钥为 PEM 格式
|
||||
String _encodeRSAPrivateKey(RSAPrivateKey privateKey) {
|
||||
// Build RSAPrivateKey structure (PKCS#8 format)
|
||||
final topSeq = ASN1Sequence();
|
||||
|
||||
// Version (0)
|
||||
topSeq.add(ASN1Integer(BigInt.zero));
|
||||
|
||||
// Modulus
|
||||
topSeq.add(ASN1Integer(privateKey.n!));
|
||||
|
||||
// Public Exponent
|
||||
topSeq.add(ASN1Integer(privateKey.exponent!));
|
||||
|
||||
// Private Exponent
|
||||
topSeq.add(ASN1Integer(privateKey.privateExponent!));
|
||||
|
||||
// Prime P
|
||||
topSeq.add(ASN1Integer(privateKey.p!));
|
||||
|
||||
// Prime Q
|
||||
topSeq.add(ASN1Integer(privateKey.q!));
|
||||
|
||||
// (Optional CRT params omitted for simplicity)
|
||||
|
||||
final derBytes = topSeq.encodedBytes;
|
||||
final base64 = base64Encode(derBytes.toList());
|
||||
return '-----BEGIN PRIVATE KEY-----\n$base64\n-----END PRIVATE KEY-----';
|
||||
}
|
||||
|
||||
// ==================== Private Key Encryption/Decryption ====================
|
||||
// ==================== 私钥加密/解密 ====================
|
||||
|
||||
/// Encrypt private key with password (AES-CBC with MD5-derived key)
|
||||
/// 用密码加密私钥(AES-CBC,密码通过 MD5 派生密钥)
|
||||
String encryptPrivateKey({
|
||||
required String privateKey,
|
||||
required String password,
|
||||
}) {
|
||||
try {
|
||||
// Generate AES key from MD5(password)
|
||||
final aesKey = _md5Hash(password);
|
||||
|
||||
// Generate random IV (16 bytes)
|
||||
final iv = _generateSecureRandomBytes(16);
|
||||
|
||||
// AES encrypt using encrypt package
|
||||
final secretKey = encrypt_pkg.Key(aesKey);
|
||||
final encryptor = encrypt_pkg.Encrypter(
|
||||
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.cbc),
|
||||
@@ -135,7 +228,6 @@ class EncryptionFlutterService {
|
||||
final encrypted = encryptor.encrypt(privateKey, iv: encrypt_pkg.IV(iv));
|
||||
final encryptedBytes = encrypted.bytes;
|
||||
|
||||
// Combine IV + encrypted data
|
||||
final combined = Uint8List(iv.length + encryptedBytes.length);
|
||||
combined.setAll(0, iv);
|
||||
combined.setAll(iv.length, encryptedBytes);
|
||||
@@ -146,23 +238,17 @@ class EncryptionFlutterService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt private key with password (AES-CBC with MD5-derived key)
|
||||
/// 用密码解密私钥(AES-CBC,密码通过 MD5 派生密钥)
|
||||
String decryptPrivateKey({
|
||||
required String encryptedPrivateKey,
|
||||
required String password,
|
||||
}) {
|
||||
try {
|
||||
// Generate AES key from MD5(password)
|
||||
final aesKey = _md5Hash(password);
|
||||
|
||||
// Decode Base64
|
||||
final combined = base64Decode(encryptedPrivateKey);
|
||||
|
||||
// Extract IV and encrypted data
|
||||
final iv = combined.sublist(0, 16);
|
||||
final encBytes = combined.sublist(16);
|
||||
|
||||
// AES decrypt
|
||||
final secretKey = encrypt_pkg.Key(aesKey);
|
||||
final encryptor = encrypt_pkg.Encrypter(
|
||||
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.cbc),
|
||||
@@ -179,29 +265,24 @@ class EncryptionFlutterService {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Session Key Management ====================
|
||||
// ==================== 会话密钥管理 ====================
|
||||
|
||||
/// Generate session key (32 bytes random)
|
||||
/// 生成会话密钥(32 字节随机)
|
||||
SessionKeyResult generateSessionKey({int initialRound = 1}) {
|
||||
final keyBytes = _generateSecureRandomBytes(sessionKeySize);
|
||||
final key = base64Encode(keyBytes);
|
||||
|
||||
return SessionKeyResult(
|
||||
key: key,
|
||||
round: initialRound,
|
||||
);
|
||||
return SessionKeyResult(key: key, round: initialRound);
|
||||
}
|
||||
|
||||
/// Encrypt session key with RSA public key
|
||||
/// 用 RSA 公钥加密会话密钥
|
||||
String encryptSessionKey({
|
||||
required String sessionKey,
|
||||
required String publicKey,
|
||||
}) {
|
||||
try {
|
||||
// Parse RSA public key
|
||||
final rsaPublicKey = _parsePublicKey(publicKey);
|
||||
|
||||
// RSA encrypt using PKCS1 padding (like native implementations)
|
||||
final cipher = PKCS1Encoding(RSAEngine());
|
||||
cipher.init(true, PublicKeyParameter<RSAPublicKey>(rsaPublicKey));
|
||||
|
||||
@@ -212,16 +293,14 @@ class EncryptionFlutterService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt session key with RSA private key
|
||||
/// 用 RSA 私钥解密会话密钥
|
||||
String decryptSessionKey({
|
||||
required String encryptedSessionKey,
|
||||
required String privateKey,
|
||||
}) {
|
||||
try {
|
||||
// Parse RSA private key
|
||||
final rsaPrivateKey = _parsePrivateKey(privateKey);
|
||||
|
||||
// RSA decrypt using PKCS1 padding (like native implementations)
|
||||
final cipher = PKCS1Encoding(RSAEngine());
|
||||
cipher.init(false, PrivateKeyParameter<RSAPrivateKey>(rsaPrivateKey));
|
||||
|
||||
@@ -232,22 +311,18 @@ class EncryptionFlutterService {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Message Encryption/Decryption ====================
|
||||
// ==================== 消息加密/解密 ====================
|
||||
|
||||
/// Encrypt message (AES-CTR with round-based key derivation)
|
||||
/// 加密消息(AES-CTR,使用 round 派生密钥)
|
||||
EncryptedMessageResult encryptMessage({
|
||||
required String plaintext,
|
||||
required String sessionKey,
|
||||
required int round,
|
||||
}) {
|
||||
try {
|
||||
// Derive key for round
|
||||
final actualKey = _deriveKeyForRound(sessionKey, round);
|
||||
|
||||
// Generate random IV (16 bytes for CTR)
|
||||
final iv = _generateSecureRandomBytes(16);
|
||||
|
||||
// AES-CTR encrypt
|
||||
final secretKey = encrypt_pkg.Key(actualKey);
|
||||
final encryptor = encrypt_pkg.Encrypter(
|
||||
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.ctr),
|
||||
@@ -256,40 +331,30 @@ class EncryptionFlutterService {
|
||||
final encrypted = encryptor.encrypt(plaintext, iv: encrypt_pkg.IV(iv));
|
||||
final encryptedBytes = encrypted.bytes;
|
||||
|
||||
// Combine IV + encrypted data
|
||||
final combined = Uint8List(iv.length + encryptedBytes.length);
|
||||
combined.setAll(0, iv);
|
||||
combined.setAll(iv.length, encryptedBytes);
|
||||
|
||||
final data = base64Encode(combined);
|
||||
|
||||
return EncryptedMessageResult(
|
||||
round: round,
|
||||
data: data,
|
||||
);
|
||||
return EncryptedMessageResult(round: round, data: data);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to encrypt message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt message (AES-CTR with round-based key derivation)
|
||||
/// 解密消息(AES-CTR,使用 round 派生密钥)
|
||||
String decryptMessage({
|
||||
required String encryptedData,
|
||||
required String sessionKey,
|
||||
required int round,
|
||||
}) {
|
||||
try {
|
||||
// Derive key for round
|
||||
final actualKey = _deriveKeyForRound(sessionKey, round);
|
||||
|
||||
// Decode Base64
|
||||
final combined = base64Decode(encryptedData);
|
||||
|
||||
// Extract IV and encrypted data
|
||||
final iv = combined.sublist(0, 16);
|
||||
final encBytes = combined.sublist(16);
|
||||
|
||||
// AES-CTR decrypt
|
||||
final secretKey = encrypt_pkg.Key(actualKey);
|
||||
final encryptor = encrypt_pkg.Encrypter(
|
||||
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.ctr),
|
||||
@@ -306,36 +371,28 @@ class EncryptionFlutterService {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Push Notification Decryption ====================
|
||||
// ==================== 推送通知解密 ====================
|
||||
|
||||
/// Set AES secret for push notification decryption
|
||||
/// 设置 AES secret(用于推送通知解密)
|
||||
void setAesSecret(String aesSecret) {
|
||||
_aesSecret = aesSecret;
|
||||
}
|
||||
|
||||
String? _aesSecret;
|
||||
|
||||
/// Decrypt push notification (AES-GCM)
|
||||
String decryptPushNotification({
|
||||
required String encryptedData,
|
||||
}) {
|
||||
/// 解密推送通知(AES-GCM)
|
||||
String decryptPushNotification({required String encryptedData}) {
|
||||
try {
|
||||
final secret = _aesSecret;
|
||||
if (secret == null) {
|
||||
throw Exception('AES_SECRET not set');
|
||||
}
|
||||
|
||||
// Convert hex string to bytes
|
||||
final secretBytes = _hexStringToBytes(secret);
|
||||
|
||||
// Decode Base64
|
||||
final combined = base64Decode(encryptedData);
|
||||
|
||||
// Extract IV and encrypted data
|
||||
final iv = combined.sublist(0, gcmIvLength);
|
||||
final encBytes = combined.sublist(gcmIvLength);
|
||||
|
||||
// AES-GCM decrypt
|
||||
final secretKey = encrypt_pkg.Key(secretBytes);
|
||||
final encryptor = encrypt_pkg.Encrypter(
|
||||
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.gcm),
|
||||
@@ -352,46 +409,105 @@ class EncryptionFlutterService {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Helper Methods ====================
|
||||
// ==================== 内部方法 ====================
|
||||
|
||||
/// Generate secure random bytes
|
||||
/// 生成安全随机字节(复用全局 Random.secure() 实例)
|
||||
Uint8List _generateSecureRandomBytes(int length) {
|
||||
final random = Random.secure();
|
||||
final bytes = Uint8List(length);
|
||||
for (var i = 0; i < length; i++) {
|
||||
bytes[i] = random.nextInt(256);
|
||||
bytes[i] = _secureRandom.nextInt(256);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// MD5 hash
|
||||
/// MD5 哈希(用于密码派生密钥)
|
||||
Uint8List _md5Hash(String input) {
|
||||
final bytes = utf8.encode(input);
|
||||
final hash = md5.convert(bytes).bytes as Uint8List;
|
||||
return hash;
|
||||
final hash = md5.convert(bytes).bytes;
|
||||
return Uint8List.fromList(hash);
|
||||
}
|
||||
|
||||
/// Derive key for round (MD5 hash of session key)
|
||||
/// 按 round 派生 AES 密钥(带 LRU 缓存)
|
||||
///
|
||||
/// 支持两种模式:
|
||||
/// - [KdfMode.md5]:MD5(sessionKey + round),兼容模式,< 0.1ms
|
||||
/// - [KdfMode.pbkdf2]:PBKDF2-HMAC-SHA256(sessionKey, salt=round),约 10-50ms
|
||||
///
|
||||
/// 两种模式都会将 round 参与派生计算,保证不同 round 产出不同密钥。
|
||||
/// 缓存命中时直接返回,跳过计算。
|
||||
/// 缓存满时淘汰最久未访问的条目(LRU)。
|
||||
Uint8List _deriveKeyForRound(String sessionKey, int targetRound) {
|
||||
// Base64 decode session key
|
||||
final keyBytes = base64Decode(sessionKey);
|
||||
final modeName = kdfMode == KdfMode.md5 ? 'md5' : 'pbkdf2';
|
||||
final cacheKey = '$sessionKey:$targetRound:$modeName';
|
||||
|
||||
// Apply MD5 for the round (simplified version)
|
||||
final hash = md5.convert(keyBytes).bytes as Uint8List;
|
||||
|
||||
return hash;
|
||||
// 缓存命中 — 移至末尾以维护 LRU 顺序
|
||||
final cached = _derivedKeyCache.remove(cacheKey);
|
||||
if (cached != null) {
|
||||
_derivedKeyCache[cacheKey] = cached;
|
||||
return cached;
|
||||
}
|
||||
|
||||
/// Parse RSA public key from PEM
|
||||
// 计算派生密钥
|
||||
final Uint8List result;
|
||||
switch (kdfMode) {
|
||||
case KdfMode.md5:
|
||||
// 将 sessionKey + round 一起参与 hash,保证不同 round 产出不同密钥
|
||||
final keyBytes = _getSessionKeyBytes(sessionKey);
|
||||
final roundBytes = utf8.encode(':$targetRound');
|
||||
final combined = Uint8List(keyBytes.length + roundBytes.length)
|
||||
..setRange(0, keyBytes.length, keyBytes)
|
||||
..setRange(
|
||||
keyBytes.length,
|
||||
keyBytes.length + roundBytes.length,
|
||||
roundBytes,
|
||||
);
|
||||
final hash = md5.convert(combined).bytes;
|
||||
result = Uint8List.fromList(hash);
|
||||
case KdfMode.pbkdf2:
|
||||
result = _pbkdf2Derive(sessionKey, targetRound);
|
||||
}
|
||||
|
||||
// LRU 淘汰:满时移除最久未访问的条目(Map 头部)
|
||||
if (_derivedKeyCache.length >= _maxDerivedKeyCacheSize) {
|
||||
_derivedKeyCache.remove(_derivedKeyCache.keys.first);
|
||||
}
|
||||
_derivedKeyCache[cacheKey] = result;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// PBKDF2-HMAC-SHA256 密钥派生
|
||||
///
|
||||
/// salt 包含 round 信息,不同 round 派生不同密钥。
|
||||
/// 迭代次数由 [pbkdf2Iterations] 控制(默认 10000)。
|
||||
/// 输出 16 字节(AES-128 密钥)。
|
||||
Uint8List _pbkdf2Derive(String sessionKey, int targetRound) {
|
||||
final keyBytes = _getSessionKeyBytes(sessionKey);
|
||||
final salt = utf8.encode('round:$targetRound');
|
||||
|
||||
final derivator = PBKDF2KeyDerivator(HMac(SHA256Digest(), 64));
|
||||
derivator.init(
|
||||
Pbkdf2Parameters(Uint8List.fromList(salt), pbkdf2Iterations, 16),
|
||||
);
|
||||
|
||||
return derivator.process(Uint8List.fromList(keyBytes));
|
||||
}
|
||||
|
||||
/// 解析 RSA 公钥 PEM(带缓存)
|
||||
RSAPublicKey _parsePublicKey(String pem) {
|
||||
final base64 = pem
|
||||
final cached = _rsaPublicKeyCache.remove(pem);
|
||||
if (cached != null) {
|
||||
_rsaPublicKeyCache[pem] = cached;
|
||||
return cached;
|
||||
}
|
||||
|
||||
final b64 = pem
|
||||
.replaceAll('-----BEGIN PUBLIC KEY-----', '')
|
||||
.replaceAll('-----END PUBLIC KEY-----', '')
|
||||
.replaceAll('\n', '')
|
||||
.trim();
|
||||
final bytes = base64Decode(base64);
|
||||
final bytes = base64Decode(b64);
|
||||
|
||||
// Parse ASN.1 DER format
|
||||
final asn1Parser = ASN1Parser(Uint8List.fromList(bytes));
|
||||
final topLevelSeq = asn1Parser.nextObject() as ASN1Sequence;
|
||||
|
||||
@@ -403,22 +519,33 @@ class EncryptionFlutterService {
|
||||
final modulus = keySeq.elements[0] as ASN1Integer;
|
||||
final publicExponent = keySeq.elements[1] as ASN1Integer;
|
||||
|
||||
return RSAPublicKey(
|
||||
final key = RSAPublicKey(
|
||||
modulus.valueAsBigInteger,
|
||||
publicExponent.valueAsBigInteger,
|
||||
);
|
||||
|
||||
if (_rsaPublicKeyCache.length >= _maxRsaKeyCacheSize) {
|
||||
_rsaPublicKeyCache.remove(_rsaPublicKeyCache.keys.first);
|
||||
}
|
||||
_rsaPublicKeyCache[pem] = key;
|
||||
return key;
|
||||
}
|
||||
|
||||
/// Parse RSA private key from PEM
|
||||
/// 解析 RSA 私钥 PEM(带缓存)
|
||||
RSAPrivateKey _parsePrivateKey(String pem) {
|
||||
final base64 = pem
|
||||
final cached = _rsaPrivateKeyCache.remove(pem);
|
||||
if (cached != null) {
|
||||
_rsaPrivateKeyCache[pem] = cached;
|
||||
return cached;
|
||||
}
|
||||
|
||||
final b64 = pem
|
||||
.replaceAll('-----BEGIN PRIVATE KEY-----', '')
|
||||
.replaceAll('-----END PRIVATE KEY-----', '')
|
||||
.replaceAll('\n', '')
|
||||
.trim();
|
||||
final bytes = base64Decode(base64);
|
||||
final bytes = base64Decode(b64);
|
||||
|
||||
// Parse ASN.1 DER format
|
||||
final asn1Parser = ASN1Parser(Uint8List.fromList(bytes));
|
||||
final keySeq = asn1Parser.nextObject() as ASN1Sequence;
|
||||
|
||||
@@ -427,20 +554,45 @@ class EncryptionFlutterService {
|
||||
final p = keySeq.elements[4] as ASN1Integer;
|
||||
final q = keySeq.elements[5] as ASN1Integer;
|
||||
|
||||
return RSAPrivateKey(
|
||||
final key = RSAPrivateKey(
|
||||
modulus.valueAsBigInteger,
|
||||
privateExponent.valueAsBigInteger,
|
||||
p.valueAsBigInteger,
|
||||
q.valueAsBigInteger,
|
||||
);
|
||||
|
||||
if (_rsaPrivateKeyCache.length >= _maxRsaKeyCacheSize) {
|
||||
_rsaPrivateKeyCache.remove(_rsaPrivateKeyCache.keys.first);
|
||||
}
|
||||
_rsaPrivateKeyCache[pem] = key;
|
||||
return key;
|
||||
}
|
||||
|
||||
/// Convert hex string to bytes
|
||||
/// session key Base64 → 字节(带缓存)
|
||||
///
|
||||
/// 同一 session key 在多条消息加解密中反复 decode,缓存后只做一次。
|
||||
Uint8List _getSessionKeyBytes(String sessionKey) {
|
||||
final cached = _sessionKeyBytesCache.remove(sessionKey);
|
||||
if (cached != null) {
|
||||
_sessionKeyBytesCache[sessionKey] = cached;
|
||||
return cached;
|
||||
}
|
||||
final bytes = base64Decode(sessionKey);
|
||||
if (_sessionKeyBytesCache.length >= _maxSessionKeyBytesCacheSize) {
|
||||
_sessionKeyBytesCache.remove(_sessionKeyBytesCache.keys.first);
|
||||
}
|
||||
_sessionKeyBytesCache[sessionKey] = bytes;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// Hex 字符串转字节
|
||||
Uint8List _hexStringToBytes(String hex) {
|
||||
final len = hex.length;
|
||||
final data = Uint8List(len ~/ 2);
|
||||
for (var i = 0; i < len; i += 2) {
|
||||
data[i ~/ 2] = (int.parse(hex[i], radix: 16) << 4) + int.parse(hex[i + 1], radix: 16);
|
||||
data[i ~/ 2] =
|
||||
(int.parse(hex[i], radix: 16) << 4) +
|
||||
int.parse(hex[i + 1], radix: 16);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -468,4 +620,3 @@ class EncryptedMessageResult {
|
||||
|
||||
EncryptedMessageResult({required this.round, required this.data});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ class EncryptionRepositoryImpl implements EncryptionRepository {
|
||||
|
||||
@override
|
||||
Future<RsaKeyPair> generateRsaKeyPair({int keySize = 1024}) async {
|
||||
final result = _service.generateRsaKeyPair(keySize: keySize);
|
||||
// 在 Isolate 中运行,避免阻塞主线程(1024-bit 约 150ms)
|
||||
final result = await _service.generateRsaKeyPairAsync(keySize: keySize);
|
||||
return RsaKeyPair(
|
||||
publicKey: result.publicKey,
|
||||
privateKey: result.privateKey,
|
||||
@@ -50,10 +51,7 @@ class EncryptionRepositoryImpl implements EncryptionRepository {
|
||||
@override
|
||||
Future<SessionKey> generateSessionKey({int initialRound = 1}) async {
|
||||
final result = _service.generateSessionKey(initialRound: initialRound);
|
||||
return SessionKey(
|
||||
key: result.key,
|
||||
round: result.round,
|
||||
);
|
||||
return SessionKey(key: result.key, round: result.round);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -91,10 +89,7 @@ class EncryptionRepositoryImpl implements EncryptionRepository {
|
||||
sessionKey: sessionKey,
|
||||
round: round,
|
||||
);
|
||||
return EncryptedMessage(
|
||||
round: result.round,
|
||||
data: result.data,
|
||||
);
|
||||
return EncryptedMessage(round: result.round, data: result.data);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -110,6 +105,11 @@ class EncryptionRepositoryImpl implements EncryptionRepository {
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 缓存管理 ====================
|
||||
|
||||
@override
|
||||
void clearDerivedKeyCache() => _service.clearDerivedKeyCache();
|
||||
|
||||
// ==================== 原生平台同步 ====================
|
||||
|
||||
@override
|
||||
@@ -147,4 +147,3 @@ class EncryptionRepositoryImpl implements EncryptionRepository {
|
||||
return _service.decryptPushNotification(encryptedData: encryptedData);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,14 @@ abstract class EncryptionRepository {
|
||||
required Map<String, Map<String, dynamic>> chatMap,
|
||||
});
|
||||
|
||||
// ==================== 缓存管理 ====================
|
||||
|
||||
/// 清空派生密钥缓存
|
||||
///
|
||||
/// 在 session key 轮换时调用,确保旧密钥的派生结果不会被复用。
|
||||
/// 不影响已加密的消息,只影响后续加解密操作的密钥派生。
|
||||
void clearDerivedKeyCache();
|
||||
|
||||
// ==================== 配置相關 ====================
|
||||
|
||||
/// 設置 AES_SECRET (用於推送解密)
|
||||
@@ -91,8 +99,5 @@ abstract class EncryptionRepository {
|
||||
|
||||
/// 解密 APNS 推送通知內容
|
||||
/// 使用 release.json 中的 AES_SECRET
|
||||
Future<String?> decryptPushNotification({
|
||||
required String encryptedData,
|
||||
});
|
||||
Future<String?> decryptPushNotification({required String encryptedData});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,7 @@ import 'package:cipher_guard_sdk/src/domain/entities/session_key.dart';
|
||||
import 'package:cipher_guard_sdk/src/domain/entities/encrypted_message.dart';
|
||||
import 'package:cipher_guard_sdk/src/presentation/wiring/cipher_guard_sdk_wiring.dart';
|
||||
|
||||
abstract class CipherGuardSdkApi
|
||||
{
|
||||
abstract class CipherGuardSdkApi {
|
||||
factory CipherGuardSdkApi() => CipherGuardSdkWiring.build();
|
||||
|
||||
// ==================== 平台版本 ====================
|
||||
@@ -22,10 +21,16 @@ abstract class CipherGuardSdkApi
|
||||
Future<RsaKeyPair> generateRsaKeyPair({int keySize = 1024});
|
||||
|
||||
/// 用密碼加密私鑰
|
||||
Future<String> encryptPrivateKey({required String privateKey, required String password,});
|
||||
Future<String> encryptPrivateKey({
|
||||
required String privateKey,
|
||||
required String password,
|
||||
});
|
||||
|
||||
/// 解密私鑰
|
||||
Future<String> decryptPrivateKey({required String encryptedPrivateKey, required String password,});
|
||||
Future<String> decryptPrivateKey({
|
||||
required String encryptedPrivateKey,
|
||||
required String password,
|
||||
});
|
||||
|
||||
// ==================== 會話金鑰管理 ====================
|
||||
|
||||
@@ -33,26 +38,87 @@ abstract class CipherGuardSdkApi
|
||||
Future<SessionKey> generateSessionKey({int initialRound = 1});
|
||||
|
||||
/// 用 RSA 公鑰加密會話金鑰
|
||||
Future<String> encryptSessionKey({required String sessionKey, required String publicKey,});
|
||||
Future<String> encryptSessionKey({
|
||||
required String sessionKey,
|
||||
required String publicKey,
|
||||
});
|
||||
|
||||
/// 用 RSA 私鑰解密會話金鑰
|
||||
Future<String> decryptSessionKey({required String encryptedSessionKey, required String privateKey,});
|
||||
Future<String> decryptSessionKey({
|
||||
required String encryptedSessionKey,
|
||||
required String privateKey,
|
||||
});
|
||||
|
||||
// ==================== 訊息加解密 ====================
|
||||
|
||||
/// 加密訊息
|
||||
Future<EncryptedMessage> encryptMessage({required String plaintext, required String sessionKey, required int round,});
|
||||
Future<EncryptedMessage> encryptMessage({
|
||||
required String plaintext,
|
||||
required String sessionKey,
|
||||
required int round,
|
||||
});
|
||||
|
||||
/// 解密訊息
|
||||
Future<String> decryptMessage({required String encryptedData, required String sessionKey, required int round,});
|
||||
Future<String> decryptMessage({
|
||||
required String encryptedData,
|
||||
required String sessionKey,
|
||||
required int round,
|
||||
});
|
||||
|
||||
// ==================== 活跃密钥注册 ====================
|
||||
|
||||
/// 注册当前用户的 RSA 密钥对(登录后调用一次,内存持久化)
|
||||
///
|
||||
/// 登录成功后从安全存储 / keychain 取出密钥对,调用此方法注入 SDK。
|
||||
/// 后续 [encryptSessionKeyWithActiveKey] / [decryptSessionKeyWithActiveKey]
|
||||
/// 直接使用内存中的密钥,不再需要调用方每次传参,也不再重复读文件。
|
||||
///
|
||||
/// 退出登录时调用 [clearActiveKeyPair]。
|
||||
void setActiveKeyPair({
|
||||
required String publicKey,
|
||||
required String privateKey,
|
||||
});
|
||||
|
||||
/// 清除内存中的活跃密钥对(退出登录时调用)
|
||||
void clearActiveKeyPair();
|
||||
|
||||
/// 用内存中的 RSA 公钥加密 session key
|
||||
///
|
||||
/// 等价于 [encryptSessionKey],但无需每次传 publicKey。
|
||||
/// 调用前必须先调 [setActiveKeyPair],否则抛 [StateError]。
|
||||
Future<String> encryptSessionKeyWithActiveKey({required String sessionKey});
|
||||
|
||||
/// 用内存中的 RSA 私钥解密 session key
|
||||
///
|
||||
/// 等价于 [decryptSessionKey],但无需每次传 privateKey。
|
||||
/// 调用前必须先调 [setActiveKeyPair],否则抛 [StateError]。
|
||||
Future<String> decryptSessionKeyWithActiveKey({
|
||||
required String encryptedSessionKey,
|
||||
});
|
||||
|
||||
// ==================== 缓存管理 ====================
|
||||
|
||||
/// 清空派生密钥缓存
|
||||
///
|
||||
/// session key 轮换后必须调用,否则旧 key 的派生结果可能被复用,
|
||||
/// 导致加解密使用错误的密钥。
|
||||
void clearDerivedKeyCache();
|
||||
|
||||
// ==================== 原生平台同步 ====================
|
||||
|
||||
/// 同步加密金鑰到原生平台 (iOS App Group)
|
||||
Future<void> syncEncryptionKey({required String chatId, required int activeRound, required int round, required String activeKey, required bool isSingle,});
|
||||
Future<void> syncEncryptionKey({
|
||||
required String chatId,
|
||||
required int activeRound,
|
||||
required int round,
|
||||
required String activeKey,
|
||||
required bool isSingle,
|
||||
});
|
||||
|
||||
/// 批量同步所有加密聊天室的金鑰
|
||||
Future<void> syncAllEncryptionKeys({required Map<String, Map<String, dynamic>> chatMap,});
|
||||
Future<void> syncAllEncryptionKeys({
|
||||
required Map<String, Map<String, dynamic>> chatMap,
|
||||
});
|
||||
|
||||
// ==================== 推送通知解密 ====================
|
||||
|
||||
@@ -60,6 +126,5 @@ abstract class CipherGuardSdkApi
|
||||
Future<void> setAesSecret({required String aesSecret});
|
||||
|
||||
/// 解密 APNS 推送通知內容
|
||||
Future<String?> decryptPushNotification({required String encryptedData,});
|
||||
Future<String?> decryptPushNotification({required String encryptedData});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,11 @@ class CipherGuardSdkApiImpl implements CipherGuardSdkApi {
|
||||
|
||||
CipherGuardSdkApiImpl({required CipherGuardSdkCore core}) : _core = core;
|
||||
|
||||
// ── 活跃密钥内存存储(登录后 setActiveKeyPair 写入,退出登录 clearActiveKeyPair 清除)──
|
||||
|
||||
String? _activePublicKey;
|
||||
String? _activePrivateKey;
|
||||
|
||||
@override
|
||||
Future<String?> platformVersion() => _core.platform.getPlatformVersion();
|
||||
|
||||
@@ -93,6 +98,47 @@ class CipherGuardSdkApiImpl implements CipherGuardSdkApi {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void setActiveKeyPair({
|
||||
required String publicKey,
|
||||
required String privateKey,
|
||||
}) {
|
||||
_activePublicKey = publicKey;
|
||||
_activePrivateKey = privateKey;
|
||||
}
|
||||
|
||||
@override
|
||||
void clearActiveKeyPair() {
|
||||
_activePublicKey = null;
|
||||
_activePrivateKey = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> encryptSessionKeyWithActiveKey({required String sessionKey}) {
|
||||
final key = _activePublicKey;
|
||||
if (key == null) {
|
||||
throw StateError('Active key pair not set. Call setActiveKeyPair first.');
|
||||
}
|
||||
return encryptSessionKey(sessionKey: sessionKey, publicKey: key);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> decryptSessionKeyWithActiveKey({
|
||||
required String encryptedSessionKey,
|
||||
}) {
|
||||
final key = _activePrivateKey;
|
||||
if (key == null) {
|
||||
throw StateError('Active key pair not set. Call setActiveKeyPair first.');
|
||||
}
|
||||
return decryptSessionKey(
|
||||
encryptedSessionKey: encryptedSessionKey,
|
||||
privateKey: key,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void clearDerivedKeyCache() => _core.encryptionRepo.clearDerivedKeyCache();
|
||||
|
||||
@override
|
||||
Future<void> syncEncryptionKey({
|
||||
required String chatId,
|
||||
@@ -123,9 +169,9 @@ class CipherGuardSdkApiImpl implements CipherGuardSdkApi {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> decryptPushNotification({
|
||||
required String encryptedData,
|
||||
}) {
|
||||
return _core.encryptionRepo.decryptPushNotification(encryptedData: encryptedData);
|
||||
Future<String?> decryptPushNotification({required String encryptedData}) {
|
||||
return _core.encryptionRepo.decryptPushNotification(
|
||||
encryptedData: encryptedData,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,18 @@ import 'package:cipher_guard_sdk/src/presentation/wiring/cipher_guard_sdk_api_im
|
||||
/// 使用 Flutter 本地加密服務,無需原生平台處理加密邏輯
|
||||
class CipherGuardSdkWiring {
|
||||
/// 構建 SDK 實例
|
||||
static CipherGuardSdkApi build() {
|
||||
///
|
||||
/// [kdfMode] — 密钥派生模式,默认 [KdfMode.md5](兼容模式)
|
||||
/// [pbkdf2Iterations] — PBKDF2 迭代次数(仅 pbkdf2 模式生效,默认 10000)
|
||||
static CipherGuardSdkApi build({
|
||||
KdfMode kdfMode = KdfMode.md5,
|
||||
int pbkdf2Iterations = 10000,
|
||||
}) {
|
||||
// 1. 創建 Flutter 加密服務
|
||||
final flutterService = EncryptionFlutterService();
|
||||
final flutterService = EncryptionFlutterService(
|
||||
kdfMode: kdfMode,
|
||||
pbkdf2Iterations: pbkdf2Iterations,
|
||||
);
|
||||
|
||||
// 2. 創建 Repository (使用 Flutter 服務)
|
||||
final repository = EncryptionRepositoryImpl(flutterService);
|
||||
@@ -39,4 +48,3 @@ class _CipherGuardPlatformImpl implements CipherGuardPlatform {
|
||||
return 'Flutter Native'; // 所有加密邏輯現在都在 Flutter 端執行
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ buildscript {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.11.1")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
|
||||
@@ -23,12 +22,11 @@ allprojects {
|
||||
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("kotlin-android")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.im_log_sdk"
|
||||
|
||||
compileSdk = 36
|
||||
|
||||
compileOptions {
|
||||
@@ -36,17 +34,9 @@ android {
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
java.srcDirs("src/main/kotlin")
|
||||
}
|
||||
getByName("test") {
|
||||
java.srcDirs("src/test/kotlin")
|
||||
}
|
||||
getByName("main") { java.srcDirs("src/main/kotlin") }
|
||||
getByName("test") { java.srcDirs("src/test/kotlin") }
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
@@ -58,9 +48,7 @@ android {
|
||||
isIncludeAndroidResources = true
|
||||
all {
|
||||
it.useJUnitPlatform()
|
||||
|
||||
it.outputs.upToDateWhen { false }
|
||||
|
||||
it.testLogging {
|
||||
events("passed", "skipped", "failed", "standardOut", "standardError")
|
||||
showStandardStreams = true
|
||||
@@ -70,6 +58,12 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
||||
testImplementation("org.mockito:mockito-core:5.0.0")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Flutter
|
||||
@preconcurrency import Flutter
|
||||
import UIKit
|
||||
|
||||
public class ImLogSdkPlugin: NSObject, FlutterPlugin {
|
||||
|
||||
@@ -19,7 +19,7 @@ A new Flutter plugin project.
|
||||
|
||||
# Flutter.framework does not contain a i386 slice.
|
||||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
|
||||
s.swift_version = '5.0'
|
||||
s.swift_version = '6.2'
|
||||
|
||||
# If your plugin requires a privacy manifest, for example if it uses any
|
||||
# required reason APIs, update the PrivacyInfo.xcprivacy file to describe your
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Cocoa
|
||||
import FlutterMacOS
|
||||
@preconcurrency import FlutterMacOS
|
||||
|
||||
public class ImLogSdkPlugin: NSObject, FlutterPlugin {
|
||||
public static func register(with registrar: FlutterPluginRegistrar) {
|
||||
|
||||
@@ -24,7 +24,7 @@ A new Flutter plugin project.
|
||||
|
||||
s.dependency 'FlutterMacOS'
|
||||
|
||||
s.platform = :osx, '10.11'
|
||||
s.platform = :osx, '14.0'
|
||||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
|
||||
s.swift_version = '5.0'
|
||||
s.swift_version = '6.2'
|
||||
end
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
group = "com.example.l10n_sdk"
|
||||
version = "1.0-SNAPSHOT"
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = "2.2.20"
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.11.1")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: "com.android.library"
|
||||
apply plugin: "kotlin-android"
|
||||
|
||||
android {
|
||||
namespace = "com.example.l10n_sdk"
|
||||
|
||||
compileSdk = 36
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += "src/main/kotlin"
|
||||
test.java.srcDirs += "src/test/kotlin"
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
||||
testImplementation("org.mockito:mockito-core:5.0.0")
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.all {
|
||||
useJUnitPlatform()
|
||||
|
||||
testLogging {
|
||||
events "passed", "skipped", "failed", "standardOut", "standardError"
|
||||
outputs.upToDateWhen {false}
|
||||
showStandardStreams = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
71
packages/l10n_sdk/android/build.gradle.kts
Normal file
71
packages/l10n_sdk/android/build.gradle.kts
Normal file
@@ -0,0 +1,71 @@
|
||||
group = "com.example.l10n_sdk"
|
||||
version = "1.0-SNAPSHOT"
|
||||
|
||||
buildscript {
|
||||
val kotlinVersion = "2.2.20"
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.11.1")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.l10n_sdk"
|
||||
compileSdk = 36
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") { java.srcDirs("src/main/kotlin") }
|
||||
getByName("test") { java.srcDirs("src/test/kotlin") }
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
all {
|
||||
it.useJUnitPlatform()
|
||||
it.outputs.upToDateWhen { false }
|
||||
it.testLogging {
|
||||
events("passed", "skipped", "failed", "standardOut", "standardError")
|
||||
showStandardStreams = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Compose BOM 统一管理子库版本,按需在各 SDK 中引入具体组件
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
||||
testImplementation("org.mockito:mockito-core:5.0.0")
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import Flutter
|
||||
@preconcurrency import Flutter
|
||||
import UIKit
|
||||
|
||||
public class L10nSdkPlugin: NSObject, FlutterPlugin {
|
||||
|
||||
@@ -19,7 +19,7 @@ A new Flutter plugin project.
|
||||
|
||||
# Flutter.framework does not contain a i386 slice.
|
||||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
|
||||
s.swift_version = '5.0'
|
||||
s.swift_version = '6.2'
|
||||
|
||||
# If your plugin requires a privacy manifest, for example if it uses any
|
||||
# required reason APIs, update the PrivacyInfo.xcprivacy file to describe your
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
group = "com.example.media_sdk"
|
||||
version = "1.0-SNAPSHOT"
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = "2.2.20"
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.11.1")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: "com.android.library"
|
||||
apply plugin: "kotlin-android"
|
||||
|
||||
android {
|
||||
namespace = "com.example.media_sdk"
|
||||
|
||||
compileSdk = 36
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += "src/main/kotlin"
|
||||
test.java.srcDirs += "src/test/kotlin"
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
||||
testImplementation("org.mockito:mockito-core:5.0.0")
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.all {
|
||||
useJUnitPlatform()
|
||||
|
||||
testLogging {
|
||||
events "passed", "skipped", "failed", "standardOut", "standardError"
|
||||
outputs.upToDateWhen {false}
|
||||
showStandardStreams = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
71
packages/media_sdk/android/build.gradle.kts
Normal file
71
packages/media_sdk/android/build.gradle.kts
Normal file
@@ -0,0 +1,71 @@
|
||||
group = "com.example.media_sdk"
|
||||
version = "1.0-SNAPSHOT"
|
||||
|
||||
buildscript {
|
||||
val kotlinVersion = "2.2.20"
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.11.1")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.media_sdk"
|
||||
compileSdk = 36
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") { java.srcDirs("src/main/kotlin") }
|
||||
getByName("test") { java.srcDirs("src/test/kotlin") }
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
all {
|
||||
it.useJUnitPlatform()
|
||||
it.outputs.upToDateWhen { false }
|
||||
it.testLogging {
|
||||
events("passed", "skipped", "failed", "standardOut", "standardError")
|
||||
showStandardStreams = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Compose BOM 统一管理子库版本,按需在各 SDK 中引入具体组件
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
||||
testImplementation("org.mockito:mockito-core:5.0.0")
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import Flutter
|
||||
@preconcurrency import Flutter
|
||||
import UIKit
|
||||
|
||||
public class MediaSdkPlugin: NSObject, FlutterPlugin {
|
||||
|
||||
@@ -19,7 +19,7 @@ A new Flutter plugin project.
|
||||
|
||||
# Flutter.framework does not contain a i386 slice.
|
||||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
|
||||
s.swift_version = '5.0'
|
||||
s.swift_version = '6.2'
|
||||
|
||||
# If your plugin requires a privacy manifest, for example if it uses any
|
||||
# required reason APIs, update the PrivacyInfo.xcprivacy file to describe your
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
group = "com.example.networks_sdk"
|
||||
version = "1.0-SNAPSHOT"
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = "2.2.20"
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.11.1")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: "com.android.library"
|
||||
apply plugin: "kotlin-android"
|
||||
|
||||
android {
|
||||
namespace = "com.example.networks_sdk"
|
||||
|
||||
compileSdk = 36
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += "src/main/kotlin"
|
||||
test.java.srcDirs += "src/test/kotlin"
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
||||
testImplementation("org.mockito:mockito-core:5.0.0")
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.all {
|
||||
useJUnitPlatform()
|
||||
|
||||
testLogging {
|
||||
events "passed", "skipped", "failed", "standardOut", "standardError"
|
||||
outputs.upToDateWhen {false}
|
||||
showStandardStreams = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
71
packages/networks_sdk/android/build.gradle.kts
Normal file
71
packages/networks_sdk/android/build.gradle.kts
Normal file
@@ -0,0 +1,71 @@
|
||||
group = "com.example.networks_sdk"
|
||||
version = "1.0-SNAPSHOT"
|
||||
|
||||
buildscript {
|
||||
val kotlinVersion = "2.2.20"
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.11.1")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.networks_sdk"
|
||||
compileSdk = 36
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") { java.srcDirs("src/main/kotlin") }
|
||||
getByName("test") { java.srcDirs("src/test/kotlin") }
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
all {
|
||||
it.useJUnitPlatform()
|
||||
it.outputs.upToDateWhen { false }
|
||||
it.testLogging {
|
||||
events("passed", "skipped", "failed", "standardOut", "standardError")
|
||||
showStandardStreams = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Compose BOM 统一管理子库版本,按需在各 SDK 中引入具体组件
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
||||
testImplementation("org.mockito:mockito-core:5.0.0")
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import Flutter
|
||||
@preconcurrency import Flutter
|
||||
import UIKit
|
||||
|
||||
public class NetworksSdkPlugin: NSObject, FlutterPlugin {
|
||||
|
||||
@@ -19,7 +19,7 @@ A new Flutter plugin project.
|
||||
|
||||
# Flutter.framework does not contain a i386 slice.
|
||||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
|
||||
s.swift_version = '5.0'
|
||||
s.swift_version = '6.2'
|
||||
|
||||
# If your plugin requires a privacy manifest, for example if it uses any
|
||||
# required reason APIs, update the PrivacyInfo.xcprivacy file to describe your
|
||||
|
||||
@@ -6,8 +6,9 @@ export 'src/presentation/facade/networks_messaging_api.dart';
|
||||
// Wiring - Implementations
|
||||
export 'src/presentation/wiring/networks_messaging_api_impl.dart';
|
||||
|
||||
// Dio 类型重导出(App 层上传 / override decodeResponse 需要,避免直接依赖 dio)
|
||||
export 'package:dio/dio.dart' show FormData, MultipartFile, Response;
|
||||
// Dio 类型重导出(App 层上传 / CancelToken / override decodeResponse 需要,避免直接依赖 dio)
|
||||
export 'package:dio/dio.dart'
|
||||
show FormData, MultipartFile, Response, CancelToken;
|
||||
|
||||
// Config
|
||||
export 'src/presentation/wiring/api_config.dart';
|
||||
@@ -18,6 +19,7 @@ export 'src/presentation/wiring/network_callbacks.dart';
|
||||
export 'src/data/dto/api_requestable.dart';
|
||||
export 'src/data/dto/api_response_wrapper.dart';
|
||||
export 'src/domain/entities/api_error.dart';
|
||||
export 'src/domain/entities/encrypted_request.dart';
|
||||
export 'src/domain/entities/http_method.dart';
|
||||
export 'src/domain/entities/api_request_type.dart';
|
||||
|
||||
@@ -27,3 +29,4 @@ export 'src/domain/entities/socket_error.dart';
|
||||
|
||||
// Annotations(代码生成)
|
||||
export 'src/annotations/api_request.dart';
|
||||
export 'src/annotations/api_response.dart';
|
||||
|
||||
@@ -1,49 +1,56 @@
|
||||
import 'package:networks_sdk/src/domain/entities/api_request_type.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/http_method.dart';
|
||||
|
||||
|
||||
/// API 请求注解 — 标记一个类为 API 请求
|
||||
///
|
||||
/// 配合 `build_runner` 代码生成器,自动生成 `ApiRequestable<T>` 协议实现,
|
||||
/// 使用侧只需定义字段 + 注解,path / method / requestType / includeToken
|
||||
/// 全部由生成器自动提供。
|
||||
///
|
||||
/// ## 使用方式
|
||||
/// ## 有响应数据(指定 responseType)
|
||||
///
|
||||
/// ```dart
|
||||
/// @ApiRequest(
|
||||
/// path: '/auth/login',
|
||||
/// path: ApiPaths.authLogin,
|
||||
/// method: HttpMethod.post,
|
||||
/// responseType: LoginData,
|
||||
/// responseType: LoginResponse,
|
||||
/// requestType: ApiRequestType.login,
|
||||
/// )
|
||||
/// @JsonSerializable()
|
||||
/// class LoginRequest extends ApiRequestable<LoginData>
|
||||
/// class LoginRequest extends ApiRequestable<LoginResponse>
|
||||
/// with _$LoginRequestApi {
|
||||
/// final String email;
|
||||
/// final String password;
|
||||
///
|
||||
/// LoginRequest({required this.email, required this.password});
|
||||
///
|
||||
/// @override
|
||||
/// Map<String, dynamic> toJson() => _$LoginRequestToJson(this);
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// 生成器自动生成 `_$LoginRequestApi` mixin,提供:
|
||||
/// - `path` → `'/auth/login'`
|
||||
/// - `method` → `HttpMethod.post`
|
||||
/// - `requestType` → `ApiRequestType.login`
|
||||
/// - `includeToken` → `false`(login 类型自动设为 false)
|
||||
/// ## 无响应数据(省略 responseType)
|
||||
///
|
||||
/// ```dart
|
||||
/// @ApiRequest(
|
||||
/// path: ApiPaths.authLogout,
|
||||
/// method: HttpMethod.post,
|
||||
/// )
|
||||
/// class LogoutRequest extends ApiRequestable<void>
|
||||
/// with _$LogoutRequestApi {
|
||||
/// LogoutRequest();
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// 生成器自动生成 `_$XxxRequestApi` mixin,提供:
|
||||
/// - `path` / `method` / `requestType` / `includeToken`
|
||||
/// - `toJson()` — 从声明字段自动生成
|
||||
/// - `responseType` 存在时:`parameters` getter 自动注册 `fromJson`
|
||||
class ApiRequest {
|
||||
/// API 路径(如 `'/auth/login'`)
|
||||
/// API 路径(如 `ApiPaths.authLogin`)
|
||||
final String path;
|
||||
|
||||
/// HTTP 方法(默认 POST)
|
||||
final HttpMethod method;
|
||||
|
||||
/// 响应类型(用于泛型绑定)
|
||||
final Type responseType;
|
||||
/// 响应数据类型(省略表示无响应数据,对应 `ApiRequestable<void>`)
|
||||
final Type? responseType;
|
||||
|
||||
/// 请求类型(决定 header 处理方式,默认 request)
|
||||
final ApiRequestType requestType;
|
||||
@@ -57,7 +64,7 @@ class ApiRequest {
|
||||
const ApiRequest({
|
||||
required this.path,
|
||||
this.method = HttpMethod.post,
|
||||
required this.responseType,
|
||||
this.responseType,
|
||||
this.requestType = ApiRequestType.request,
|
||||
this.includeToken,
|
||||
this.customHeaders,
|
||||
|
||||
31
packages/networks_sdk/lib/src/annotations/api_response.dart
Normal file
31
packages/networks_sdk/lib/src/annotations/api_response.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
/// Response DTO 无需任何注解。
|
||||
///
|
||||
/// 生成器从 `@ApiRequest(responseType: T)` 声明出发,自动推导并生成
|
||||
/// `_$TFromJson` 反序列化函数,以及所有嵌套自定义类型的 `fromJson`。
|
||||
///
|
||||
/// ## 使用方式(只需标注 Request 类)
|
||||
///
|
||||
/// ```dart
|
||||
/// // Request:唯一需要注解的地方
|
||||
/// @ApiRequest(path: ApiPaths.authLogin, responseType: LoginResponse)
|
||||
/// class LoginRequest extends ApiRequestable<LoginResponse>
|
||||
/// with _$LoginRequestApi {
|
||||
/// final String email;
|
||||
/// final String password;
|
||||
/// LoginRequest({required this.email, required this.password});
|
||||
/// }
|
||||
///
|
||||
/// // Response:纯 Dart 类,零注解,零样板
|
||||
/// class LoginResponse {
|
||||
/// @JsonKey(name: 'access_token') final String accessToken;
|
||||
/// final LoginProfile profile;
|
||||
/// const LoginResponse({required this.accessToken, required this.profile});
|
||||
/// // 生成器自动提供 _$LoginResponseFromJson
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// 此文件保留作占位符,供未来扩展使用(如自定义序列化策略)。
|
||||
/// 当前版本 Response 类无需标注任何注解,所有 fromJson 由生成器自动推导。
|
||||
class ApiResponse {
|
||||
const ApiResponse();
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:networks_sdk/src/data/datasources/http/interceptor/auth_interceptor.dart';
|
||||
import 'package:networks_sdk/src/data/datasources/http/interceptor/encryption_interceptor.dart';
|
||||
import 'package:networks_sdk/src/data/datasources/http/interceptor/logging_interceptor.dart';
|
||||
import 'package:networks_sdk/src/data/datasources/http/interceptor/retry_interceptor.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/api_error.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
|
||||
|
||||
/// REST API 客户端
|
||||
/// 基于 Dio,提供 `executeRequest<T>` 唯一入口
|
||||
/// 基于 Dio,提供请求执行入口
|
||||
///
|
||||
/// 拦截器链顺序(onRequest):Auth → 自定义 → Retry → Logging → Encryption
|
||||
///
|
||||
/// Dio 的 onResponse / onError 按 **逆序** 执行,因此实际响应处理为:
|
||||
/// `Encryption(解密) → Logging → Retry(业务码判断) → 自定义 → Auth`
|
||||
///
|
||||
/// EncryptionInterceptor 放最后,保证:
|
||||
/// - onRequest 最后加密(其他拦截器操作明文)
|
||||
/// - onResponse 最先解密(其他拦截器看到明文,业务码判断正常工作)
|
||||
///
|
||||
/// 使用方式:
|
||||
/// ```dart
|
||||
@@ -28,12 +38,15 @@ class ApiClient {
|
||||
receiveTimeout: const Duration(seconds: 60),
|
||||
);
|
||||
|
||||
// 挂载拦截器(顺序:Auth → 自定义 → Retry → Logging)
|
||||
// 挂载拦截器
|
||||
// onRequest 顺序:Auth → 自定义 → Retry → Logging → Encryption
|
||||
// onResponse 逆序:Encryption(解密) → Logging → Retry(业务码) → 自定义 → Auth
|
||||
_dio.interceptors.addAll([
|
||||
AuthInterceptor(config),
|
||||
if (additionalInterceptors != null) ...additionalInterceptors,
|
||||
RetryInterceptor(config: config, dio: _dio),
|
||||
LoggingInterceptor(onLog: config.onLog),
|
||||
EncryptionInterceptor(config),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -49,13 +62,13 @@ class ApiClient {
|
||||
return const ApiError.timeout();
|
||||
case DioExceptionType.connectionError:
|
||||
return const ApiError.noNetworkConnection();
|
||||
case DioExceptionType.cancel:
|
||||
return const ApiError.cancelled();
|
||||
default:
|
||||
if (e.response != null) {
|
||||
return ApiError.apiError(
|
||||
code: e.response!.statusCode ?? 0,
|
||||
message: e.response!.statusMessage ??
|
||||
e.message ??
|
||||
'Request failed',
|
||||
message: e.response!.statusMessage ?? e.message ?? 'Request failed',
|
||||
);
|
||||
}
|
||||
return ApiError.networkError(e.message ?? 'Network error');
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
|
||||
|
||||
/// 加密拦截器(预留给 cipher_guard_sdk)
|
||||
///
|
||||
/// 在拦截器链中位于最末位:
|
||||
/// onRequest 顺序:`Auth → Custom → Retry → Logging → Encryption`
|
||||
/// onResponse 逆序:`Encryption(解密) → Logging → Retry(业务码) → Custom → Auth`
|
||||
///
|
||||
/// 放最后是因为 Dio onResponse 按逆序执行——加密拦截器最先解密,
|
||||
/// 后续的 RetryInterceptor 才能正确判断业务错误码、Token 过期等。
|
||||
///
|
||||
/// 回调为 null 时自动跳过,不影响正常请求流程。
|
||||
/// 后续 cipher_guard_sdk 接入后,App 层在 ApiConfig 中注入
|
||||
/// `onEncryptRequest` / `onDecryptResponse` 即可启用加密。
|
||||
///
|
||||
/// ## 加密能力
|
||||
///
|
||||
/// 与简单的 body 加解密不同,本拦截器支持完整的请求改写:
|
||||
/// - 路径加密(如 `/api/login` → `/api/hex(encrypt(login))`)
|
||||
/// - 请求体加密(Map → base64 字符串)
|
||||
/// - Header 注入(X-Token、X-Signature、secret-key 等)
|
||||
/// - Content-Type 覆盖(application/json → text/plain)
|
||||
///
|
||||
/// 加密回调接收原始 path、headers、body,返回 [EncryptedRequest],
|
||||
/// 拦截器根据非 null 字段覆盖请求。
|
||||
///
|
||||
/// ## Token 重试与重新加密
|
||||
///
|
||||
/// 瞬态错误重试(5xx / 超时):复用已加密的请求,不重复加密。
|
||||
/// Token 刷新重试:加密 headers 可能包含过期 token 衍生值
|
||||
/// (如 X-Token、X-Signature),需要恢复加密前状态并用新 token 重新加密。
|
||||
/// RetryInterceptor 通过 `_needsReEncryption` 标记通知本拦截器重新加密。
|
||||
class EncryptionInterceptor extends Interceptor {
|
||||
/// extra 标记键:请求已加密,瞬态重试时跳过
|
||||
static const _encryptedKey = '__encrypted__';
|
||||
|
||||
/// extra 标记键:加密前的请求快照(path / body / contentType)
|
||||
static const _preEncryptSnapshotKey = '__preEncryptSnapshot__';
|
||||
|
||||
/// extra 标记键:加密回调注入的 header key 列表
|
||||
static const _encryptionAddedHeadersKey = '__encryptionAddedHeaders__';
|
||||
|
||||
final ApiConfig _config;
|
||||
|
||||
EncryptionInterceptor(this._config);
|
||||
|
||||
@override
|
||||
void onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) async {
|
||||
final encrypt = _config.onEncryptRequest;
|
||||
if (encrypt == null) {
|
||||
handler.next(options);
|
||||
return;
|
||||
}
|
||||
|
||||
// Token 重试 + 已加密 → 恢复加密前状态,用新 token 上下文重新加密
|
||||
// 旧的加密 headers(如 X-Token、X-Signature)可能包含过期 token 信息
|
||||
if (options.extra[_encryptedKey] == true &&
|
||||
options.extra['_needsReEncryption'] == true) {
|
||||
_restorePreEncryptState(options);
|
||||
options.extra.remove('_needsReEncryption');
|
||||
}
|
||||
|
||||
// 已加密(瞬态错误重试)→ 复用加密请求,不重复加密
|
||||
if (options.extra[_encryptedKey] == true) {
|
||||
handler.next(options);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 保存加密前快照,Token 重试时恢复
|
||||
options.extra[_preEncryptSnapshotKey] = {
|
||||
'path': options.path,
|
||||
'data': options.data,
|
||||
'contentType': options.contentType,
|
||||
};
|
||||
|
||||
// 收集当前 headers(转为 Map<String, String>)
|
||||
final currentHeaders = <String, String>{};
|
||||
options.headers.forEach((key, value) {
|
||||
if (value != null) currentHeaders[key] = value.toString();
|
||||
});
|
||||
|
||||
final result = await encrypt(options.path, currentHeaders, options.data);
|
||||
|
||||
// 根据非 null 字段覆盖请求
|
||||
if (result.path != null) {
|
||||
options.path = result.path!;
|
||||
}
|
||||
if (result.body != null) {
|
||||
options.data = result.body;
|
||||
}
|
||||
if (result.headers != null) {
|
||||
options.headers.addAll(result.headers!);
|
||||
// 记录加密注入的 header key,Token 重试时移除
|
||||
options.extra[_encryptionAddedHeadersKey] = result.headers!.keys
|
||||
.toList();
|
||||
}
|
||||
if (result.contentType != null) {
|
||||
options.contentType = result.contentType;
|
||||
}
|
||||
|
||||
// 标记已加密,防止瞬态重试时重复加密
|
||||
options.extra[_encryptedKey] = true;
|
||||
|
||||
_config.onLog?.call(
|
||||
'Request encrypted: ${options.path}',
|
||||
tag: 'Encryption',
|
||||
);
|
||||
|
||||
handler.next(options);
|
||||
} catch (e) {
|
||||
_config.onLog?.call('Request encryption failed: $e', tag: 'Encryption');
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: options,
|
||||
message: 'Request encryption failed: $e',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 恢复加密前的请求状态
|
||||
///
|
||||
/// Token 重试场景:旧的加密数据(path / body / headers)可能包含过期 token,
|
||||
/// 需要恢复原始状态后用新 token 上下文重新加密。
|
||||
///
|
||||
/// AuthInterceptor 已在本轮重试中注入了新 token headers,
|
||||
/// 这里只需移除上次加密注入的 headers(如 X-Token、X-Signature),
|
||||
/// 保留 Auth 设置的新 token。
|
||||
void _restorePreEncryptState(RequestOptions options) {
|
||||
final snapshot =
|
||||
options.extra[_preEncryptSnapshotKey] as Map<String, dynamic>?;
|
||||
if (snapshot != null) {
|
||||
options.path = snapshot['path'] as String;
|
||||
options.data = snapshot['data'];
|
||||
options.contentType = snapshot['contentType'] as String?;
|
||||
}
|
||||
|
||||
// 移除上次加密注入的 headers
|
||||
final addedHeaders = options.extra[_encryptionAddedHeadersKey] as List?;
|
||||
if (addedHeaders != null) {
|
||||
for (final key in addedHeaders) {
|
||||
options.headers.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 清除加密标记和快照
|
||||
options.extra.remove(_encryptedKey);
|
||||
options.extra.remove(_encryptionAddedHeadersKey);
|
||||
|
||||
_config.onLog?.call(
|
||||
'Pre-encrypt state restored for token retry',
|
||||
tag: 'Encryption',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) async {
|
||||
final decrypt = _config.onDecryptResponse;
|
||||
if (decrypt == null) {
|
||||
handler.next(response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 跳过 null 响应
|
||||
if (response.data == null) {
|
||||
handler.next(response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 响应已是 Map → 未加密(health check、非加密端点等),跳过解密
|
||||
// 加密模式下响应通常是 String(base64)或 List<int>(bytes)
|
||||
// TODO: 接入加密后,若服务端所有端点都加密,可移除此判断
|
||||
if (response.data is Map<String, dynamic>) {
|
||||
handler.next(response);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final decrypted = await decrypt(response.data as Object);
|
||||
response.data = decrypted;
|
||||
|
||||
_config.onLog?.call(
|
||||
'Response decrypted: ${response.requestOptions.path}',
|
||||
tag: 'Encryption',
|
||||
);
|
||||
|
||||
handler.next(response);
|
||||
} catch (e) {
|
||||
_config.onLog?.call('Response decryption failed: $e', tag: 'Encryption');
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: response.requestOptions,
|
||||
response: response,
|
||||
message: 'Response decryption failed: $e',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,41 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:networks_sdk/src/data/datasources/http/token_refresh_manager.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
|
||||
|
||||
|
||||
/// 重试拦截器
|
||||
///
|
||||
/// 两层重试机制:
|
||||
///
|
||||
/// 1. **Token 刷新重试**(onResponse)
|
||||
/// 检测 Token 过期响应 → 触发刷新回调 → 用新 Token 重试原请求
|
||||
/// 检测 Token 过期响应 → 触发 [TokenRefreshManager] → 用新 Token 重试原请求
|
||||
///
|
||||
/// 2. **瞬态错误重试**(onError)
|
||||
/// 5xx / 超时 / 连接失败 → 指数退避 + jitter → 自动重试
|
||||
/// 由 [ApiConfig.maxRetries] 控制(默认 0 = 不启用)
|
||||
///
|
||||
/// 另外在 onResponse 中处理强制登出码和业务错误码。
|
||||
///
|
||||
/// 两层独立运作,可叠加。
|
||||
class RetryInterceptor extends Interceptor {
|
||||
final ApiConfig config;
|
||||
final Dio dio;
|
||||
|
||||
/// Token 刷新锁(防止多个请求同时刷新)
|
||||
bool _isRefreshing = false;
|
||||
Completer<bool>? _refreshCompleter;
|
||||
final TokenRefreshManager _tokenManager;
|
||||
|
||||
final _random = Random();
|
||||
|
||||
RetryInterceptor({required this.config, required this.dio});
|
||||
RetryInterceptor({required this.config, required this.dio})
|
||||
: _tokenManager = TokenRefreshManager(
|
||||
onTokenRefresh: config.onTokenRefresh,
|
||||
onLog: config.onLog,
|
||||
timeout: config.tokenRefreshTimeout,
|
||||
reuseWindow: config.tokenReuseWindow,
|
||||
onGetTokenExpiry: config.onGetTokenExpiry,
|
||||
proactiveRefreshThreshold: config.proactiveRefreshThreshold,
|
||||
);
|
||||
|
||||
// ── Token 刷新重试 ────────────────────────────────────────────────────────
|
||||
// ── 响应处理(Token 过期 / 强制登出 / 业务错误码)──────────────────────
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
@@ -40,13 +46,12 @@ class RetryInterceptor extends Interceptor {
|
||||
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final code = _parseCode(data['code']);
|
||||
final message = data['message'] as String? ?? '';
|
||||
final requestPath = response.requestOptions.path;
|
||||
|
||||
// 检查强制登出
|
||||
if (config.forceLogoutCodes.contains(code)) {
|
||||
config.onLog?.call(
|
||||
'Force logout detected (code: $code)',
|
||||
tag: 'Network',
|
||||
);
|
||||
config.onLog?.call('Force logout detected (code: $code)', tag: 'Network');
|
||||
config.onForceLogout?.call();
|
||||
handler.reject(
|
||||
DioException(
|
||||
@@ -58,8 +63,9 @@ class RetryInterceptor extends Interceptor {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 Token 过期
|
||||
if (config.tokenExpiredCodes.contains(code)) {
|
||||
// 检查 Token 过期(跳过已标记为 token 重试的请求,防止递归)
|
||||
if (config.tokenExpiredCodes.contains(code) &&
|
||||
response.requestOptions.extra['_isTokenRetry'] != true) {
|
||||
config.onLog?.call(
|
||||
'Token expired (code: $code), refreshing...',
|
||||
tag: 'Network',
|
||||
@@ -68,6 +74,17 @@ class RetryInterceptor extends Interceptor {
|
||||
return;
|
||||
}
|
||||
|
||||
// 业务错误码拦截:非 0 且不在特殊码集合中
|
||||
if (code != 0 && config.onBusinessError != null) {
|
||||
final handled = config.onBusinessError!(code, message, requestPath);
|
||||
if (handled) {
|
||||
// App 层已处理 → 标记,让 decodeResponse 跳过二次抛错
|
||||
response.requestOptions.extra['_businessErrorHandled'] = true;
|
||||
handler.next(response);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
@@ -76,9 +93,9 @@ class RetryInterceptor extends Interceptor {
|
||||
Response response,
|
||||
ResponseInterceptorHandler handler,
|
||||
) async {
|
||||
final refreshSuccess = await _refreshToken();
|
||||
final newToken = await _tokenManager.refreshIfNeeded();
|
||||
|
||||
if (!refreshSuccess) {
|
||||
if (newToken == null) {
|
||||
config.onLog?.call('Token refresh failed', tag: 'Network');
|
||||
config.onForceLogout?.call();
|
||||
handler.reject(
|
||||
@@ -91,12 +108,17 @@ class RetryInterceptor extends Interceptor {
|
||||
return;
|
||||
}
|
||||
|
||||
// 刷新成功,用新 token 重试原请求
|
||||
// 刷新成功,更新 config 并用新 token 重试原请求
|
||||
config.updateToken(newToken);
|
||||
config.onLog?.call('Token refreshed, retrying...', tag: 'Network');
|
||||
try {
|
||||
final options = response.requestOptions;
|
||||
// 更新 header 中的 token
|
||||
options.headers['token'] = config.token;
|
||||
options.headers['token'] = newToken;
|
||||
// 标记为 token 重试请求,防止重试后再次进入 _handleTokenExpired 造成递归
|
||||
options.extra['_isTokenRetry'] = true;
|
||||
// 通知 EncryptionInterceptor:token 变了,需要用新 token 上下文重新加密
|
||||
// 旧的加密 headers(如 X-Token、X-Signature)可能包含过期 token 信息
|
||||
options.extra['_needsReEncryption'] = true;
|
||||
|
||||
final retryResponse = await dio.fetch(options);
|
||||
handler.resolve(retryResponse);
|
||||
@@ -105,41 +127,6 @@ class RetryInterceptor extends Interceptor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Token 刷新(串行锁)
|
||||
/// 多个请求同时过期时,只刷新一次,其余等待
|
||||
Future<bool> _refreshToken() async {
|
||||
if (_isRefreshing) {
|
||||
// 等待正在进行的刷新
|
||||
return _refreshCompleter?.future ?? Future.value(false);
|
||||
}
|
||||
|
||||
_isRefreshing = true;
|
||||
_refreshCompleter = Completer<bool>();
|
||||
|
||||
try {
|
||||
if (config.onTokenRefresh == null) {
|
||||
_refreshCompleter!.complete(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
final newToken = await config.onTokenRefresh!();
|
||||
final success = newToken != null;
|
||||
|
||||
if (success) {
|
||||
config.updateToken(newToken);
|
||||
}
|
||||
|
||||
_refreshCompleter!.complete(success);
|
||||
return success;
|
||||
} catch (e) {
|
||||
_refreshCompleter!.complete(false);
|
||||
return false;
|
||||
} finally {
|
||||
_isRefreshing = false;
|
||||
_refreshCompleter = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 瞬态错误重试(指数退避 + jitter)────────────────────────────────────
|
||||
|
||||
@override
|
||||
@@ -198,7 +185,9 @@ class RetryInterceptor extends Interceptor {
|
||||
int _backoffDelay(int attempt) {
|
||||
final baseMs = config.retryBaseDelay.inMilliseconds;
|
||||
final exponentialMs = min(baseMs * pow(2, attempt).toInt(), 30000);
|
||||
final jitterMs = _random.nextInt((exponentialMs * 0.25).toInt().clamp(1, 7500));
|
||||
final jitterMs = _random.nextInt(
|
||||
(exponentialMs * 0.25).toInt().clamp(1, 7500),
|
||||
);
|
||||
return exponentialMs + jitterMs;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:networks_sdk/src/presentation/wiring/network_callbacks.dart';
|
||||
|
||||
/// Token 刷新管理器
|
||||
///
|
||||
/// 两种刷新模式:
|
||||
///
|
||||
/// 1. **被动刷新**([refreshIfNeeded])— 拦截器检测到 token 过期后调用
|
||||
/// 2. **主动刷新**([proactivelyRefreshIfNeeded])— 解析 JWT exp,
|
||||
/// 距过期不足阈值时提前刷新,避免带过期 token 发请求
|
||||
///
|
||||
/// 两种模式共享串行锁和时间窗口保护:
|
||||
/// - **串行锁** — 同一时刻只执行一次刷新,其余请求等待同一 Completer
|
||||
/// - **时间窗口** — 刷新成功后 [reuseWindow] 内再次调用直接返回缓存 token
|
||||
/// - **超时保护** — 刷新回调超过 [timeout] 自动失败,防止死锁
|
||||
class TokenRefreshManager {
|
||||
final OnTokenRefresh? onTokenRefresh;
|
||||
final OnLog? onLog;
|
||||
|
||||
/// 刷新超时时间(防止 onTokenRefresh 卡住导致所有请求阻塞)
|
||||
final Duration timeout;
|
||||
|
||||
/// 时间窗口:刷新成功后此时间内再次调用直接返回缓存 token
|
||||
final Duration reuseWindow;
|
||||
|
||||
/// Token 过期时间解析(App 层注入 JWT exp 解析逻辑)
|
||||
final OnGetTokenExpiry? onGetTokenExpiry;
|
||||
|
||||
/// 主动刷新阈值:距过期不足此时间时提前刷新(默认 1 小时)
|
||||
final Duration proactiveRefreshThreshold;
|
||||
|
||||
/// 当前正在进行的刷新任务(null = 空闲)
|
||||
Completer<String?>? _completer;
|
||||
|
||||
/// 上次刷新成功的时间戳
|
||||
DateTime? _lastRefreshTime;
|
||||
|
||||
/// 上次刷新成功的 token(时间窗口内复用)
|
||||
String? _lastToken;
|
||||
|
||||
TokenRefreshManager({
|
||||
this.onTokenRefresh,
|
||||
this.onLog,
|
||||
this.timeout = const Duration(seconds: 10),
|
||||
this.reuseWindow = const Duration(seconds: 3),
|
||||
this.onGetTokenExpiry,
|
||||
this.proactiveRefreshThreshold = const Duration(hours: 1),
|
||||
});
|
||||
|
||||
/// 执行 token 刷新(如果需要)
|
||||
///
|
||||
/// 返回新 token(刷新成功或在时间窗口内),
|
||||
/// 返回 null = 刷新失败或超时。
|
||||
Future<String?> refreshIfNeeded() async {
|
||||
// 1. 时间窗口:最近刷新过且未超时 → 直接返回缓存的 token
|
||||
if (_isWithinReuseWindow()) {
|
||||
_log('Token refreshed recently, reusing');
|
||||
return _lastToken;
|
||||
}
|
||||
|
||||
// 2. 有正在进行的刷新 → 等待同一 Completer
|
||||
final existing = _completer;
|
||||
if (existing != null) {
|
||||
_log('Waiting for ongoing token refresh');
|
||||
return existing.future;
|
||||
}
|
||||
|
||||
// 3. 发起新的刷新
|
||||
if (onTokenRefresh == null) {
|
||||
_log('No onTokenRefresh callback configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
final completer = Completer<String?>();
|
||||
_completer = completer;
|
||||
|
||||
try {
|
||||
final newToken = await onTokenRefresh!().timeout(
|
||||
timeout,
|
||||
onTimeout: () {
|
||||
_log('Token refresh timed out after ${timeout.inSeconds}s');
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
final success = newToken != null && newToken.isNotEmpty;
|
||||
|
||||
if (success) {
|
||||
_lastRefreshTime = DateTime.now();
|
||||
_lastToken = newToken;
|
||||
_log('Token refreshed successfully');
|
||||
} else {
|
||||
_log('Token refresh failed (null or empty token)');
|
||||
}
|
||||
|
||||
// 先 complete 再清引用,确保等待者能拿到结果
|
||||
completer.complete(success ? newToken : null);
|
||||
return success ? newToken : null;
|
||||
} catch (e) {
|
||||
_log('Token refresh error: $e');
|
||||
completer.complete(null);
|
||||
return null;
|
||||
} finally {
|
||||
// 清理引用(Completer 已 complete,等待者不受影响)
|
||||
_completer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查 token 是否即将过期,是则主动刷新
|
||||
///
|
||||
/// 解析 [currentToken] 的过期时间,距过期不足 [proactiveRefreshThreshold]
|
||||
/// 时调用 [refreshIfNeeded] 刷新。复用串行锁和超时保护。
|
||||
///
|
||||
/// 返回新 token(已刷新)或 null(不需要刷新 / 刷新失败 / 无法解析过期时间)。
|
||||
Future<String?> proactivelyRefreshIfNeeded(String? currentToken) async {
|
||||
if (currentToken == null || onGetTokenExpiry == null) return null;
|
||||
|
||||
final expiry = onGetTokenExpiry!(currentToken);
|
||||
if (expiry == null) return null;
|
||||
|
||||
final remaining = expiry.difference(DateTime.now());
|
||||
if (remaining > proactiveRefreshThreshold) {
|
||||
_log(
|
||||
'Token valid (expires in ${remaining.inMinutes}min), skip proactive refresh',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
_log(
|
||||
'Token expiring soon (${remaining.inMinutes}min left), proactively refreshing',
|
||||
);
|
||||
return refreshIfNeeded();
|
||||
}
|
||||
|
||||
/// 重置状态(登出时调用)
|
||||
void reset() {
|
||||
_lastRefreshTime = null;
|
||||
_lastToken = null;
|
||||
// 不清理 _completer,让正在等待的请求正常结束
|
||||
}
|
||||
|
||||
bool _isWithinReuseWindow() {
|
||||
final lastTime = _lastRefreshTime;
|
||||
if (lastTime == null) return false;
|
||||
return DateTime.now().difference(lastTime) < reuseWindow;
|
||||
}
|
||||
|
||||
void _log(String message) {
|
||||
onLog?.call(message, tag: 'TokenRefresh');
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,25 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:networks_sdk/src/data/datasources/http/api_client.dart';
|
||||
import 'package:networks_sdk/src/data/dto/api_requestable.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/api_error.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/api_request_type.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/network_callbacks.dart';
|
||||
import '../../../networks_sdk_platform_interface.dart';
|
||||
import '../../domain/entities/http_method.dart';
|
||||
|
||||
class NetworksSdkMethodChannelDataSource
|
||||
{
|
||||
/// 网络层数据源
|
||||
///
|
||||
/// 封装 [ApiClient],提供两种请求入口:
|
||||
/// - [executeRequest] — 统一请求入口(标准 / Upload / 流式)
|
||||
/// - [executeDownload] — 带进度的文件下载(支持断点续传)
|
||||
///
|
||||
/// 流式(SSE)请求也走 [executeRequest],由业务 Request 类 override
|
||||
/// `decodeResponse` 处理 SSE 解析。SDK 内部根据
|
||||
/// `requestType == ApiRequestType.stream` 自动切换 `ResponseType.plain`。
|
||||
class NetworksSdkMethodChannelDataSource {
|
||||
final NetworksSdkPlatform platform;
|
||||
|
||||
late ApiClient apiClient;
|
||||
@@ -16,44 +27,51 @@ class NetworksSdkMethodChannelDataSource
|
||||
NetworksSdkMethodChannelDataSource(this.platform);
|
||||
|
||||
Future<String?> getPlatformVersion() async {
|
||||
return await getPlatformVersion();
|
||||
return await platform.getPlatformVersion();
|
||||
}
|
||||
|
||||
void initialize(ApiConfig apiConfig) {
|
||||
apiClient = ApiClient(config: apiConfig);
|
||||
}
|
||||
|
||||
/// 执行 API 请求 — 唯一入口
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// 统一请求入口
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// 执行 API 请求 — 统一入口
|
||||
///
|
||||
/// 流程:网络前置检查 → 构建 URL → 设置元数据 → 执行请求 → 解码响应 → 错误映射
|
||||
/// 拦截器负责:header 注入、Token 刷新重试、日志
|
||||
/// 支持三种请求类型,由 `request.requestType` 控制行为:
|
||||
/// - `request` / `login` — 标准 JSON 请求
|
||||
/// - `upload` — 文件上传(FormData / 二进制)
|
||||
/// - `stream` — SSE / chunked,内部用 `ResponseType.plain` 获取原始文本,
|
||||
/// 由业务 Request 类 override `decodeResponse` 处理 SSE 解析
|
||||
///
|
||||
/// 流程:网络前置检查 → 构建 URL → 设置元数据 → 执行请求
|
||||
/// → 响应变换(可选,stream 类型跳过)→ 解码响应 → 错误映射
|
||||
///
|
||||
/// 拦截器负责:header 注入、加密/解密、Token 刷新重试、业务错误拦截、日志
|
||||
///
|
||||
/// Upload 类型支持两种模式:
|
||||
/// - 自有后端上传:path 为相对路径,自动拼接 baseURL
|
||||
/// - S3 presigned URL:path 以 http 开头,直接使用全路径
|
||||
Future<T?> executeRequest<T>(ApiRequestable<T> request) async {
|
||||
// 前置检查:网络不可用时直接抛错,避免无效请求
|
||||
if (apiClient.config.onCheckNetworkAvailable != null) {
|
||||
final available = await apiClient.config.onCheckNetworkAvailable!();
|
||||
if (!available) {
|
||||
apiClient.config.onLog?.call(
|
||||
'Network unavailable, abort request: ${request.path}',
|
||||
tag: 'ApiClient',
|
||||
);
|
||||
throw const ApiError.noNetworkConnection();
|
||||
}
|
||||
}
|
||||
Future<T?> executeRequest<T>(
|
||||
ApiRequestable<T> request, {
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
await _checkNetwork(request.path);
|
||||
|
||||
try {
|
||||
// Upload 且 path 以 http 开头 → 直接用全路径(S3 presigned URL)
|
||||
// 否则 → 拼接 baseURL
|
||||
final isUpload = request.requestType == ApiRequestType.upload;
|
||||
final isStream = request.requestType == ApiRequestType.stream;
|
||||
final path = request.path;
|
||||
final url = (isUpload && path.startsWith('http')) ? path : '${apiClient.config.baseURL}$path';
|
||||
final url = (isUpload && path.startsWith('http'))
|
||||
? path
|
||||
: '${apiClient.config.baseURL}$path';
|
||||
|
||||
// 将请求元数据写入 extra,供拦截器读取
|
||||
final options = Options(
|
||||
method: request.method.value,
|
||||
// 流式请求用 plain,Dio 返回原始文本,由 decodeResponse 解析 SSE
|
||||
responseType: isStream ? ResponseType.plain : null,
|
||||
extra: {
|
||||
'requestType': request.requestType,
|
||||
'includeToken': request.includeToken,
|
||||
@@ -62,19 +80,22 @@ class NetworksSdkMethodChannelDataSource
|
||||
);
|
||||
|
||||
// 访问 parameters 触发代码生成器的 fromJson 注册
|
||||
// (@ApiRequest 生成的 mixin 在 parameters getter 中注册响应类型)
|
||||
final params = request.parameters;
|
||||
|
||||
// GET → queryParameters;POST/PUT/DELETE/PATCH → JSON body;Upload → uploadData
|
||||
final isGet = request.method == HttpMethod.get;
|
||||
final response = await apiClient.dio.request(
|
||||
url,
|
||||
data: isUpload ? request.uploadData : (isGet ? null : params),
|
||||
queryParameters: isGet ? params : null,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
|
||||
// 解码响应(Upload 类型通常需要 override decodeResponse)
|
||||
// 响应变换:stream 类型由 decodeResponse 自行处理,不做变换
|
||||
if (!isStream) {
|
||||
_applyResponseTransform(response);
|
||||
}
|
||||
|
||||
return request.decodeResponse(response);
|
||||
} on DioException catch (e) {
|
||||
throw apiClient.mapDioError(e);
|
||||
@@ -85,4 +106,184 @@ class NetworksSdkMethodChannelDataSource
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// 文件下载
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// 下载文件到本地路径
|
||||
///
|
||||
/// 支持进度回调和断点续传(通过 HTTP Range header 实现)。
|
||||
///
|
||||
/// 非续传模式直接用 Dio.download(高效,内部流式写入)。
|
||||
/// 续传模式用 stream + FileMode.append,因为 Dio.download 始终从
|
||||
/// 文件头部写入,无法正确追加到已下载部分之后。
|
||||
///
|
||||
/// [url] — 下载 URL(完整路径或相对路径,相对路径自动拼接 baseURL)
|
||||
/// [savePath] — 本地保存路径
|
||||
/// [onProgress] — 下载进度回调
|
||||
/// [cancelToken] — 取消令牌
|
||||
/// [resume] — 是否断点续传(文件已存在时从断点继续下载)
|
||||
/// [headers] — 额外请求头
|
||||
Future<void> executeDownload({
|
||||
required String url,
|
||||
required String savePath,
|
||||
OnDownloadProgress? onProgress,
|
||||
CancelToken? cancelToken,
|
||||
bool resume = false,
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
await _checkNetwork(url);
|
||||
|
||||
try {
|
||||
final fullUrl = url.startsWith('http')
|
||||
? url
|
||||
: '${apiClient.config.baseURL}$url';
|
||||
|
||||
final extraHeaders = <String, String>{};
|
||||
if (headers != null) extraHeaders.addAll(headers);
|
||||
|
||||
// 断点续传:读取已下载部分的大小,设置 Range header
|
||||
int startBytes = 0;
|
||||
if (resume) {
|
||||
final file = File(savePath);
|
||||
if (file.existsSync()) {
|
||||
startBytes = file.lengthSync();
|
||||
extraHeaders['Range'] = 'bytes=$startBytes-';
|
||||
}
|
||||
}
|
||||
|
||||
if (resume && startBytes > 0) {
|
||||
// 续传模式:stream + append,确保新数据追加到文件末尾
|
||||
await _downloadWithResume(
|
||||
url: fullUrl,
|
||||
savePath: savePath,
|
||||
startBytes: startBytes,
|
||||
headers: extraHeaders,
|
||||
onProgress: onProgress,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
} else {
|
||||
// 普通下载:Dio.download(高效,内部流式写入)
|
||||
await apiClient.dio.download(
|
||||
fullUrl,
|
||||
savePath,
|
||||
cancelToken: cancelToken,
|
||||
deleteOnError: true,
|
||||
options: Options(
|
||||
headers: extraHeaders.isEmpty ? null : extraHeaders,
|
||||
extra: {
|
||||
'requestType': ApiRequestType.download,
|
||||
'includeToken': true,
|
||||
},
|
||||
),
|
||||
onReceiveProgress: onProgress != null
|
||||
? (received, total) => onProgress(received, total)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
throw apiClient.mapDioError(e);
|
||||
} on ApiError {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
throw ApiError.unknown(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// 断点续传下载:stream 响应 + FileMode.append
|
||||
///
|
||||
/// Dio.download 内部用 FileMode.write(从头覆盖),无法正确续传。
|
||||
/// 这里手动读流并追加写入文件。
|
||||
///
|
||||
/// 如果服务端不支持 Range 请求(返回 200 而非 206),
|
||||
/// 自动回退为覆盖写入,防止文件损坏。
|
||||
Future<void> _downloadWithResume({
|
||||
required String url,
|
||||
required String savePath,
|
||||
required int startBytes,
|
||||
required Map<String, String> headers,
|
||||
OnDownloadProgress? onProgress,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
final response = await apiClient.dio.get<ResponseBody>(
|
||||
url,
|
||||
cancelToken: cancelToken,
|
||||
options: Options(
|
||||
responseType: ResponseType.stream,
|
||||
headers: headers.isEmpty ? null : headers,
|
||||
extra: {'requestType': ApiRequestType.download, 'includeToken': true},
|
||||
),
|
||||
);
|
||||
|
||||
final stream = response.data?.stream;
|
||||
if (stream == null) return;
|
||||
|
||||
// 检查服务端是否接受了 Range 请求
|
||||
// 206 = 支持续传,追加写入
|
||||
// 200 = 不支持 Range,返回完整文件,需要覆盖写入
|
||||
final isPartialContent = response.statusCode == 206;
|
||||
final effectiveStartBytes = isPartialContent ? startBytes : 0;
|
||||
|
||||
if (!isPartialContent) {
|
||||
apiClient.config.onLog?.call(
|
||||
'Server does not support Range, falling back to full download',
|
||||
tag: 'Download',
|
||||
);
|
||||
}
|
||||
|
||||
// Content-Length 是本次传输量
|
||||
final contentLength =
|
||||
int.tryParse(response.headers.value('content-length') ?? '') ?? -1;
|
||||
final totalBytes = contentLength > 0
|
||||
? contentLength + effectiveStartBytes
|
||||
: -1;
|
||||
|
||||
final file = File(savePath);
|
||||
// 不支持续传时用 write 覆盖,支持时用 append 追加
|
||||
final fileMode = isPartialContent
|
||||
? FileMode.writeOnlyAppend
|
||||
: FileMode.writeOnly;
|
||||
final raf = file.openSync(mode: fileMode);
|
||||
int received = effectiveStartBytes;
|
||||
|
||||
try {
|
||||
await for (final chunk in stream) {
|
||||
raf.writeFromSync(chunk);
|
||||
received += chunk.length;
|
||||
onProgress?.call(received, totalBytes);
|
||||
}
|
||||
} finally {
|
||||
raf.closeSync();
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// 内部辅助
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// 网络前置检查,不可用时直接抛 [ApiError.noNetworkConnection]
|
||||
Future<void> _checkNetwork(String path) async {
|
||||
if (apiClient.config.onCheckNetworkAvailable != null) {
|
||||
final available = await apiClient.config.onCheckNetworkAvailable!();
|
||||
if (!available) {
|
||||
apiClient.config.onLog?.call(
|
||||
'Network unavailable, abort request: $path',
|
||||
tag: 'ApiClient',
|
||||
);
|
||||
throw const ApiError.noNetworkConnection();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用响应变换(如果 App 层注入了 onTransformResponse)
|
||||
void _applyResponseTransform(Response response) {
|
||||
final transform = apiClient.config.onTransformResponse;
|
||||
if (transform == null) return;
|
||||
if (response.data is! Map<String, dynamic>) return;
|
||||
|
||||
final transformed = transform(response.data as Map<String, dynamic>);
|
||||
if (transformed != null) {
|
||||
response.data = transformed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io' as io;
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
@@ -9,10 +11,12 @@ import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
/// WebSocket 长连接客户端
|
||||
///
|
||||
/// 提供:
|
||||
/// - 连接 / 断连 / 自动重连(指数退避)
|
||||
/// - 连接 / 断连 / 自动重连(指数退避,支持无限重连)
|
||||
/// - 双层心跳(底层 ping + 应用层 heartbeat)
|
||||
/// - Stream 输出(消息、连接状态、错误)
|
||||
/// - Stream 输出(JSON 消息、原始字符串、二进制、连接状态、错误)
|
||||
/// - 生命周期感知(前后台切换)
|
||||
/// - Token 热更新(不断连)
|
||||
/// - 消息加密/解密钩子(预留给 cipher_guard_sdk,ping/pong 走明文不加密)
|
||||
///
|
||||
/// ## 使用方式
|
||||
///
|
||||
@@ -28,6 +32,9 @@ import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
/// // 发消息
|
||||
/// await client.send({'type': 'chat', 'data': {...}});
|
||||
///
|
||||
/// // Token 热更新(不断连,下次重连自动使用新 token)
|
||||
/// client.updateToken('new_token');
|
||||
///
|
||||
/// // 断连
|
||||
/// await client.disconnect();
|
||||
/// ```
|
||||
@@ -53,9 +60,19 @@ class SocketClient {
|
||||
Timer? _reconnectTimer;
|
||||
final _random = Random();
|
||||
|
||||
// ── 消息处理 ──
|
||||
|
||||
/// 异步消息处理链,保证解密场景下消息按到达顺序处理
|
||||
///
|
||||
/// 无解密回调时不使用(同步处理,天然有序)。
|
||||
/// 有解密回调时,每条消息的处理链在前一条之后执行,
|
||||
/// 即使解密耗时不同也不会乱序。
|
||||
Future<void>? _messageProcessingChain;
|
||||
|
||||
// ── Stream Controllers ──
|
||||
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
final _rawMessageController = StreamController<String>.broadcast();
|
||||
final _binaryMessageController = StreamController<Uint8List>.broadcast();
|
||||
final _connectionStateController =
|
||||
StreamController<SocketConnectionState>.broadcast();
|
||||
final _errorController = StreamController<SocketError>.broadcast();
|
||||
@@ -93,12 +110,20 @@ class SocketClient {
|
||||
}
|
||||
|
||||
/// 当前是否已连接
|
||||
bool get isConnected =>
|
||||
_connectionState == SocketConnectionState.connected;
|
||||
bool get isConnected => _connectionState == SocketConnectionState.connected;
|
||||
|
||||
/// 当前连接状态
|
||||
SocketConnectionState get connectionState => _connectionState;
|
||||
|
||||
/// Token 热更新(不断开连接)
|
||||
///
|
||||
/// 仅更新内部持有的 token,下次重连时自动使用新 token。
|
||||
/// 适用于 token 刷新后同步到 WebSocket 的场景。
|
||||
void updateToken(String token) {
|
||||
_currentToken = token;
|
||||
_log('Token updated (no reconnect)');
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// 公开 API — 发送
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
@@ -109,6 +134,8 @@ class SocketClient {
|
||||
}
|
||||
|
||||
/// 发送原始字符串
|
||||
///
|
||||
/// 如果配置了 [SocketConfig.onEncryptMessage],发送前自动加密。
|
||||
Future<bool> sendString(String message) async {
|
||||
if (!isConnected || _channel == null) {
|
||||
_emitError(SocketError.sendFailed('Not connected'));
|
||||
@@ -116,7 +143,27 @@ class SocketClient {
|
||||
}
|
||||
|
||||
try {
|
||||
_channel!.sink.add(message);
|
||||
String payload = message;
|
||||
if (config.onEncryptMessage != null) {
|
||||
payload = await config.onEncryptMessage!(message);
|
||||
}
|
||||
_channel!.sink.add(payload);
|
||||
return true;
|
||||
} catch (e) {
|
||||
_emitError(SocketError.sendFailed(e.toString()));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送二进制数据
|
||||
Future<bool> sendBytes(List<int> bytes) async {
|
||||
if (!isConnected || _channel == null) {
|
||||
_emitError(SocketError.sendFailed('Not connected'));
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
_channel!.sink.add(bytes);
|
||||
return true;
|
||||
} catch (e) {
|
||||
_emitError(SocketError.sendFailed(e.toString()));
|
||||
@@ -134,6 +181,9 @@ class SocketClient {
|
||||
/// 原始字符串消息流(JSON 解析失败的也走这里)
|
||||
Stream<String> get rawMessageStream => _rawMessageController.stream;
|
||||
|
||||
/// 二进制消息流
|
||||
Stream<Uint8List> get binaryMessageStream => _binaryMessageController.stream;
|
||||
|
||||
/// 连接状态变化流
|
||||
Stream<SocketConnectionState> get connectionStateStream =>
|
||||
_connectionStateController.stream;
|
||||
@@ -171,6 +221,7 @@ class SocketClient {
|
||||
await _doDisconnect(reason: 'Dispose');
|
||||
await _messageController.close();
|
||||
await _rawMessageController.close();
|
||||
await _binaryMessageController.close();
|
||||
await _connectionStateController.close();
|
||||
await _errorController.close();
|
||||
}
|
||||
@@ -197,7 +248,16 @@ class SocketClient {
|
||||
_log('Connecting to $url');
|
||||
|
||||
try {
|
||||
// 构建最终 URL(拼接 token)
|
||||
// 构建最终连接 URL
|
||||
//
|
||||
// 有 onBuildConnectUrl 回调时,App 层完全控制 URL(路径加密、
|
||||
// token 加密、添加 cipher 参数等)。
|
||||
// 无回调时使用默认行为:URL 后追加 ?token=xxx。
|
||||
final String connectUrlString;
|
||||
|
||||
if (config.onBuildConnectUrl != null) {
|
||||
connectUrlString = config.onBuildConnectUrl!(url, _currentToken);
|
||||
} else {
|
||||
final connectUri = _currentToken != null
|
||||
? uri.replace(
|
||||
queryParameters: {
|
||||
@@ -206,15 +266,18 @@ class SocketClient {
|
||||
},
|
||||
)
|
||||
: uri;
|
||||
connectUrlString = connectUri.toString();
|
||||
}
|
||||
|
||||
// 创建 WebSocket 连接
|
||||
_channel = IOWebSocketChannel.connect(
|
||||
connectUri,
|
||||
pingInterval: config.pingInterval,
|
||||
);
|
||||
|
||||
// 等待连接就绪
|
||||
await _channel!.ready.timeout(config.connectTimeout);
|
||||
// 创建 WebSocket 连接(通过 dart:io 支持压缩选项)
|
||||
final rawSocket = await io.WebSocket.connect(
|
||||
connectUrlString,
|
||||
compression: config.enableCompression
|
||||
? io.CompressionOptions.compressionDefault
|
||||
: io.CompressionOptions.compressionOff,
|
||||
).timeout(config.connectTimeout);
|
||||
rawSocket.pingInterval = config.pingInterval;
|
||||
_channel = IOWebSocketChannel(rawSocket);
|
||||
|
||||
_log('Connected');
|
||||
_updateConnectionState(SocketConnectionState.connected);
|
||||
@@ -271,25 +334,72 @@ class SocketClient {
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
void _handleMessage(dynamic data) {
|
||||
if (data is! String) {
|
||||
// 非字符串消息(如二进制),走 rawMessageStream
|
||||
_rawMessageController.add(data.toString());
|
||||
// 二进制消息不需要解密,直接分发
|
||||
if (data is List<int>) {
|
||||
if (!_binaryMessageController.isClosed) {
|
||||
_binaryMessageController.add(
|
||||
data is Uint8List ? data : Uint8List.fromList(data),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 pong 心跳回复
|
||||
if (data is! String) {
|
||||
if (!_rawMessageController.isClosed) {
|
||||
_rawMessageController.add(data.toString());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// pong 是传输层心跳,不经过业务加解密,直接匹配
|
||||
if (data == 'pong') {
|
||||
_onPongReceived();
|
||||
return;
|
||||
}
|
||||
|
||||
// 尝试 JSON 解析
|
||||
if (config.onDecryptMessage != null) {
|
||||
// 有解密回调 → 链式异步处理,保证消息按到达顺序分发
|
||||
// 避免解密耗时不同导致后到的消息先完成解密、先分发
|
||||
final previous = _messageProcessingChain ?? Future.value();
|
||||
_messageProcessingChain = previous.then((_) => _processTextMessage(data));
|
||||
} else {
|
||||
// 无解密回调 → 同步处理,天然有序
|
||||
_dispatchTextMessage(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// 异步处理文本消息(解密 → 分发)
|
||||
Future<void> _processTextMessage(String data) async {
|
||||
// dispose 期间可能有残留的链式任务,直接跳过
|
||||
if (_messageController.isClosed) return;
|
||||
|
||||
String text;
|
||||
try {
|
||||
final json = jsonDecode(data) as Map<String, dynamic>;
|
||||
text = await config.onDecryptMessage!(data);
|
||||
} catch (e) {
|
||||
_log('Message decryption failed: $e');
|
||||
if (!_rawMessageController.isClosed) {
|
||||
_rawMessageController.add(data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_dispatchTextMessage(text);
|
||||
}
|
||||
|
||||
/// 分发文本消息(JSON 解析 → 投递到对应 stream)
|
||||
///
|
||||
/// pong 已在 `_handleMessage` 中提前拦截,不会到这里。
|
||||
void _dispatchTextMessage(String text) {
|
||||
try {
|
||||
final json = jsonDecode(text) as Map<String, dynamic>;
|
||||
if (!_messageController.isClosed) {
|
||||
_messageController.add(json);
|
||||
}
|
||||
} catch (_) {
|
||||
// JSON 解析失败,走原始消息流
|
||||
_rawMessageController.add(data);
|
||||
if (!_rawMessageController.isClosed) {
|
||||
_rawMessageController.add(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,12 +442,16 @@ class SocketClient {
|
||||
if (_waitingForPong) return;
|
||||
|
||||
_waitingForPong = true;
|
||||
|
||||
// ping/pong 是传输层心跳,不经过业务加解密
|
||||
// 保证即使加密密钥过期/轮换失败,心跳仍然正常工作
|
||||
_channel?.sink.add('ping');
|
||||
_log('♥ ping');
|
||||
|
||||
// 启动 pong 超时计时器
|
||||
_pongTimeoutTimer = Timer(config.pongTimeout, () {
|
||||
if (_waitingForPong) {
|
||||
_log('Pong timeout, reconnecting...');
|
||||
_log('♥ pong timeout, reconnecting...');
|
||||
_waitingForPong = false;
|
||||
_emitError(const SocketError.pingTimeout());
|
||||
_doDisconnect(reason: 'Pong timeout');
|
||||
@@ -347,6 +461,7 @@ class SocketClient {
|
||||
}
|
||||
|
||||
void _onPongReceived() {
|
||||
_log('♥ pong');
|
||||
_waitingForPong = false;
|
||||
_pongTimeoutTimer?.cancel();
|
||||
_pongTimeoutTimer = null;
|
||||
@@ -360,7 +475,9 @@ class SocketClient {
|
||||
if (_manualDisconnect || !config.autoReconnect || _isBackground) return;
|
||||
if (_connectionState == SocketConnectionState.reconnecting) return;
|
||||
|
||||
if (_reconnectAttempts >= config.maxReconnectAttempts) {
|
||||
// 非无限重连模式下检查重连次数上限
|
||||
if (!config.unlimitedReconnect &&
|
||||
_reconnectAttempts >= config.maxReconnectAttempts) {
|
||||
_log('Max reconnect attempts reached ($_reconnectAttempts)');
|
||||
_reconnectAttempts = 0;
|
||||
return;
|
||||
@@ -375,11 +492,16 @@ class SocketClient {
|
||||
pow(2, _reconnectAttempts).toInt() * 1000,
|
||||
config.maxReconnectDelay.inMilliseconds,
|
||||
);
|
||||
final jitterMs = _random.nextInt((baseDelayMs * 0.25).toInt().clamp(1, 7500));
|
||||
final jitterMs = _random.nextInt(
|
||||
(baseDelayMs * 0.25).toInt().clamp(1, 7500),
|
||||
);
|
||||
final delay = Duration(milliseconds: baseDelayMs + jitterMs);
|
||||
|
||||
_log('Reconnecting in ${delay.inMilliseconds}ms '
|
||||
'(attempt $_reconnectAttempts/${config.maxReconnectAttempts})');
|
||||
final attemptsInfo = config.unlimitedReconnect
|
||||
? 'attempt $_reconnectAttempts/unlimited'
|
||||
: 'attempt $_reconnectAttempts/${config.maxReconnectAttempts}';
|
||||
|
||||
_log('Reconnecting in ${delay.inMilliseconds}ms ($attemptsInfo)');
|
||||
|
||||
_reconnectTimer = Timer(delay, () async {
|
||||
// 重连前检查网络
|
||||
@@ -393,6 +515,19 @@ class SocketClient {
|
||||
}
|
||||
}
|
||||
|
||||
// 重连前回调(App 层刷新即将过期的 token 等)
|
||||
if (config.onBeforeReconnect != null) {
|
||||
try {
|
||||
await config.onBeforeReconnect!();
|
||||
} catch (e) {
|
||||
_log('onBeforeReconnect failed: $e, schedule next reconnect');
|
||||
// 重置状态以允许下次 _startReconnect 进入(防止卡死在 reconnecting)
|
||||
_updateConnectionState(SocketConnectionState.disconnected);
|
||||
_startReconnect();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_doConnect();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,35 +4,36 @@ import 'package:networks_sdk/src/domain/entities/api_error.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/api_request_type.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/http_method.dart';
|
||||
|
||||
|
||||
/// API 请求基类
|
||||
///
|
||||
/// 使用侧只需:字段 + path + method,其余全部有默认实现。
|
||||
/// 只需 `@ApiRequest` 一个注解,声明字段和构造函数即可:
|
||||
/// - `toJson()` 由 mixin 自动生成(只序列化类自身字段,不含继承属性)
|
||||
/// - `path / method / requestType / includeToken` 由 mixin 自动提供
|
||||
/// - Response 的 fromJson 在 mixin 的 `parameters` getter 中自动注册
|
||||
/// - **不需要** `@JsonSerializable`,**不需要** 手写 `fromJson`
|
||||
///
|
||||
/// ```dart
|
||||
/// @JsonSerializable()
|
||||
/// class LoginRequest extends ApiRequestable<LoginData> {
|
||||
/// @ApiRequest(
|
||||
/// path: ApiPaths.authLogin,
|
||||
/// method: HttpMethod.post,
|
||||
/// responseType: LoginData,
|
||||
/// requestType: ApiRequestType.login,
|
||||
/// )
|
||||
/// class LoginRequest extends ApiRequestable<LoginData>
|
||||
/// with _$LoginRequestApi {
|
||||
/// final String email;
|
||||
/// final String password;
|
||||
///
|
||||
/// LoginRequest({required this.email, required this.password});
|
||||
///
|
||||
/// factory LoginRequest.fromJson(Map<String, dynamic> json) =>
|
||||
/// _$LoginRequestFromJson(json);
|
||||
/// @override
|
||||
/// Map<String, dynamic> toJson() => _$LoginRequestToJson(this);
|
||||
///
|
||||
/// @override
|
||||
/// String get path => '/auth/login';
|
||||
/// @override
|
||||
/// HttpMethod get method => HttpMethod.post;
|
||||
/// @override
|
||||
/// bool get includeToken => false;
|
||||
/// // 完毕!一行样板代码都不用写
|
||||
/// }
|
||||
///
|
||||
/// // 文件顶层注册一次(一行)
|
||||
/// final _reg = registerResponse<LoginData>(LoginData.fromJson);
|
||||
/// ```
|
||||
///
|
||||
/// 字段名映射:在字段上加 `@JsonKey(name: 'server_name')` 即可,
|
||||
/// 生成器会读取并使用该名称作为 JSON 键。
|
||||
///
|
||||
/// 特殊请求(如 upload):在类中 override `toJson()` 即可,
|
||||
/// 类的 override 优先于 mixin。
|
||||
abstract class ApiRequestable<T> {
|
||||
/// API 路径(如 '/auth/login')
|
||||
String get path;
|
||||
@@ -40,8 +41,8 @@ abstract class ApiRequestable<T> {
|
||||
/// HTTP 方法
|
||||
HttpMethod get method;
|
||||
|
||||
/// 序列化为 JSON(由 @JsonSerializable 自动生成)
|
||||
/// 子类 override 返回 `_$XxxToJson(this)` 即可
|
||||
/// 序列化为 JSON(由 @ApiRequest 生成器在 mixin 中自动生成)
|
||||
/// Upload 等特殊请求可在类中 override 返回空 map
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
/// 请求参数 — 默认调用 toJson(),upload 类型返回 null
|
||||
@@ -106,13 +107,17 @@ abstract class ApiRequestable<T> {
|
||||
final mapFunc = fromJsonFunc as T Function(Map<String, dynamic>);
|
||||
return mapFunc(json);
|
||||
}
|
||||
throw FormatException('Expected Map<String, dynamic>, got ${json.runtimeType}',);
|
||||
throw FormatException(
|
||||
'Expected Map<String, dynamic>, got ${json.runtimeType}',
|
||||
);
|
||||
}
|
||||
|
||||
final wrapper = ApiResponseWrapper<T>.fromJson(data, fromJsonObject);
|
||||
|
||||
// 业务错误码检查
|
||||
if (wrapper.code != 0) {
|
||||
// 业务错误码检查(RetryInterceptor 已处理的跳过,防止双重抛错)
|
||||
final handledByInterceptor =
|
||||
response.requestOptions.extra['_businessErrorHandled'] == true;
|
||||
if (wrapper.code != 0 && !handledByInterceptor) {
|
||||
throw ApiError.apiError(
|
||||
code: wrapper.code,
|
||||
message: wrapper.message ?? 'API error (code: ${wrapper.code})',
|
||||
@@ -141,8 +146,9 @@ final fromJsonRegistry = <Type, Function>{};
|
||||
/// ```dart
|
||||
/// final _reg = registerResponse<LoginData>(LoginData.fromJson);
|
||||
/// ```
|
||||
T Function(Map<String, dynamic>)? registerResponse<T>(T Function(Map<String, dynamic>) fromJson,)
|
||||
{
|
||||
T Function(Map<String, dynamic>)? registerResponse<T>(
|
||||
T Function(Map<String, dynamic>) fromJson,
|
||||
) {
|
||||
fromJsonRegistry[T] = fromJson;
|
||||
return fromJson;
|
||||
}
|
||||
@@ -152,9 +158,7 @@ T Function(Map<String, dynamic>)? registerResponse<T>(T Function(Map<String, dyn
|
||||
/// ```dart
|
||||
/// final _reg = registerResponseObject<DeviceList>(DeviceList.fromJson);
|
||||
/// ```
|
||||
T Function(Object?)? registerResponseObject<T>(
|
||||
T Function(Object?) fromJson,
|
||||
) {
|
||||
T Function(Object?)? registerResponseObject<T>(T Function(Object?) fromJson) {
|
||||
fromJsonRegistry[T] = fromJson;
|
||||
return fromJson;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
/// API 响应信封解析器
|
||||
/// API 响应包装解析器
|
||||
/// 统一处理 { code, message/msg, data } 格式的服务器响应
|
||||
class ApiResponseWrapper<T> {
|
||||
final int code;
|
||||
final String? message;
|
||||
final T? data;
|
||||
|
||||
const ApiResponseWrapper({
|
||||
required this.code,
|
||||
this.message,
|
||||
this.data,
|
||||
});
|
||||
const ApiResponseWrapper({required this.code, this.message, this.data});
|
||||
|
||||
factory ApiResponseWrapper.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
@@ -28,8 +24,7 @@ class ApiResponseWrapper<T> {
|
||||
}
|
||||
|
||||
// message 字段:兼容 message 和 msg
|
||||
final message =
|
||||
json['message'] as String? ?? json['msg'] as String?;
|
||||
final message = json['message'] as String? ?? json['msg'] as String?;
|
||||
|
||||
// 解码 data(null-safe:logout / delete 等接口可能无 data)
|
||||
final rawData = json['data'];
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:networks_sdk/src/data/datasources/socket/socket_client.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/socket_connection_state.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/socket_error.dart';
|
||||
import 'package:networks_sdk/src/domain/repositories/networks_messaging_repository.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/socket_config.dart';
|
||||
|
||||
/// Messaging Repository Implementation (Data)
|
||||
/// [NetworksMessagingRepository] 的实现,透传给 [SocketClient]
|
||||
class NetworksMessagingRepositoryImpl implements NetworksMessagingRepository {
|
||||
SocketClient? _socketClient;
|
||||
bool _isInitialized = false;
|
||||
@@ -47,6 +49,12 @@ class NetworksMessagingRepositoryImpl implements NetworksMessagingRepository {
|
||||
return _socketClient!.connectionState;
|
||||
}
|
||||
|
||||
@override
|
||||
void updateToken(String token) {
|
||||
_checkInitialized();
|
||||
_socketClient!.updateToken(token);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> send(Map<String, dynamic> message) {
|
||||
_checkInitialized();
|
||||
@@ -59,6 +67,12 @@ class NetworksMessagingRepositoryImpl implements NetworksMessagingRepository {
|
||||
return _socketClient!.sendString(message);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> sendBytes(List<int> bytes) {
|
||||
_checkInitialized();
|
||||
return _socketClient!.sendBytes(bytes);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Map<String, dynamic>> get messageStream {
|
||||
_checkInitialized();
|
||||
@@ -71,6 +85,12 @@ class NetworksMessagingRepositoryImpl implements NetworksMessagingRepository {
|
||||
return _socketClient!.rawMessageStream;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Uint8List> get binaryMessageStream {
|
||||
_checkInitialized();
|
||||
return _socketClient!.binaryMessageStream;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<SocketConnectionState> get connectionStateStream {
|
||||
_checkInitialized();
|
||||
@@ -104,4 +124,3 @@ class NetworksMessagingRepositoryImpl implements NetworksMessagingRepository {
|
||||
_isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
// Repository Impl
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:networks_sdk/src/data/dto/api_requestable.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/network_callbacks.dart';
|
||||
|
||||
import '../../domain/repositories/networks_sdk_repository.dart';
|
||||
import '../datasources/networks_sdk_method_channel_datasource.dart';
|
||||
|
||||
class NetworksSdkRepositoryImpl implements NetworksSdkRepository
|
||||
{
|
||||
/// [NetworksSdkRepository] 的实现,透传给 [NetworksSdkMethodChannelDataSource]
|
||||
class NetworksSdkRepositoryImpl implements NetworksSdkRepository {
|
||||
final NetworksSdkMethodChannelDataSource _datasource;
|
||||
|
||||
const NetworksSdkRepositoryImpl(this._datasource);
|
||||
@@ -23,7 +25,29 @@ class NetworksSdkRepositoryImpl implements NetworksSdkRepository
|
||||
}
|
||||
|
||||
@override
|
||||
Future<T?> executeRequest<T>(ApiRequestable<T> request) {
|
||||
return _datasource.executeRequest(request);
|
||||
Future<T?> executeRequest<T>(
|
||||
ApiRequestable<T> request, {
|
||||
CancelToken? cancelToken,
|
||||
}) {
|
||||
return _datasource.executeRequest(request, cancelToken: cancelToken);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> executeDownload({
|
||||
required String url,
|
||||
required String savePath,
|
||||
OnDownloadProgress? onProgress,
|
||||
CancelToken? cancelToken,
|
||||
bool resume = false,
|
||||
Map<String, String>? headers,
|
||||
}) {
|
||||
return _datasource.executeDownload(
|
||||
url: url,
|
||||
savePath: savePath,
|
||||
onProgress: onProgress,
|
||||
cancelToken: cancelToken,
|
||||
resume: resume,
|
||||
headers: headers,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ class ApiError with _$ApiError implements Exception {
|
||||
required int code,
|
||||
required String message,
|
||||
}) = _ApiError;
|
||||
|
||||
/// 请求被 CancelToken 取消
|
||||
const factory ApiError.cancelled() = _Cancelled;
|
||||
const factory ApiError.unknown(String? message) = _Unknown;
|
||||
}
|
||||
|
||||
@@ -25,6 +28,7 @@ extension ApiErrorExtension on ApiError {
|
||||
networkError: (message) => 'Network error: $message',
|
||||
decodingError: (message) => 'Decoding error: $message',
|
||||
apiError: (code, message) => message,
|
||||
cancelled: () => 'Request cancelled',
|
||||
unknown: (message) => message ?? 'Unknown error',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,4 +8,13 @@ enum ApiRequestType {
|
||||
|
||||
/// 文件上传(multipart,不序列化 parameters)
|
||||
upload,
|
||||
|
||||
/// 流式请求(SSE / chunked)
|
||||
///
|
||||
/// SDK 内部自动切换 `ResponseType.plain`,Dio 返回原始文本。
|
||||
/// 业务 Request 类 override `decodeResponse` 处理 SSE 解析。
|
||||
stream,
|
||||
|
||||
/// 文件下载(带进度回调,支持断点续传)
|
||||
download,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/// HTTP 请求加密结果
|
||||
///
|
||||
/// 加密回调返回此对象,[EncryptionInterceptor] 根据非 null 字段覆盖原始请求。
|
||||
/// 未设置的字段保持原值不变。
|
||||
///
|
||||
/// 设计依据:HTTP 加密模式下,加密后需要同时修改:
|
||||
/// - 路径(原文 path 加密为 hex 编码)
|
||||
/// - 请求体(JSON body 加密为 base64 字符串,不再是 Map)
|
||||
/// - Headers(添加 X-Token、X-Signature、secret-key 等加密 header)
|
||||
/// - Content-Type(JSON → text/plain)
|
||||
///
|
||||
/// ```dart
|
||||
/// // 加密回调返回示例
|
||||
/// EncryptedRequest(
|
||||
/// path: '/api/${hexEncode(encrypt(originalPath))}',
|
||||
/// body: base64Encode(encrypt(jsonBody)),
|
||||
/// headers: {
|
||||
/// 'X-Token': encryptedToken,
|
||||
/// 'X-Signature': signature,
|
||||
/// 'secret-key': clientPublicKey,
|
||||
/// },
|
||||
/// contentType: 'text/plain',
|
||||
/// )
|
||||
/// ```
|
||||
class EncryptedRequest {
|
||||
/// 加密后的路径
|
||||
///
|
||||
/// null 表示不修改路径。
|
||||
/// 如需加密,拦截器会用此值替换 `RequestOptions.path`。
|
||||
final String? path;
|
||||
|
||||
/// 加密后的请求体
|
||||
///
|
||||
/// null 表示不修改 body。
|
||||
/// 类型不限于 Map — 加密后通常是 base64 字符串或 bytes。
|
||||
final Object? body;
|
||||
|
||||
/// 需要添加或覆盖的 headers
|
||||
///
|
||||
/// null 表示不修改 headers。
|
||||
/// 拦截器会将这些 header 合并到请求中(覆盖同名 header)。
|
||||
final Map<String, String>? headers;
|
||||
|
||||
/// 覆盖 Content-Type
|
||||
///
|
||||
/// null 表示不修改。加密后通常是 `text/plain`(body 已是字符串,非 JSON)。
|
||||
final String? contentType;
|
||||
|
||||
const EncryptedRequest({
|
||||
this.path,
|
||||
this.body,
|
||||
this.headers,
|
||||
this.contentType,
|
||||
});
|
||||
}
|
||||
@@ -1,49 +1,45 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:networks_sdk/src/domain/entities/socket_connection_state.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/socket_error.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/socket_config.dart';
|
||||
|
||||
/// Messaging Repository Interface (Domain)
|
||||
/// Messaging Repository 接口
|
||||
abstract class NetworksMessagingRepository {
|
||||
/// Initialize with config
|
||||
void initialize(SocketConfig config);
|
||||
|
||||
/// Connect to messaging server
|
||||
Future<bool> connect(String url, {String? token});
|
||||
|
||||
/// Disconnect from server
|
||||
Future<void> disconnect();
|
||||
|
||||
/// Check if connected
|
||||
bool get isConnected;
|
||||
|
||||
/// Current connection state
|
||||
SocketConnectionState get connectionState;
|
||||
|
||||
/// Send a JSON message
|
||||
/// Token 热更新(不断连)
|
||||
void updateToken(String token);
|
||||
|
||||
Future<bool> send(Map<String, dynamic> message);
|
||||
|
||||
/// Send a raw string message
|
||||
Future<bool> sendString(String message);
|
||||
|
||||
/// Stream of incoming parsed JSON messages
|
||||
/// 发送二进制数据
|
||||
Future<bool> sendBytes(List<int> bytes);
|
||||
|
||||
Stream<Map<String, dynamic>> get messageStream;
|
||||
|
||||
/// Stream of raw string messages
|
||||
Stream<String> get rawMessageStream;
|
||||
|
||||
/// Stream of connection state changes
|
||||
/// 二进制消息流
|
||||
Stream<Uint8List> get binaryMessageStream;
|
||||
|
||||
Stream<SocketConnectionState> get connectionStateStream;
|
||||
|
||||
/// Stream of errors
|
||||
Stream<SocketError> get errorStream;
|
||||
|
||||
/// Called when app enters foreground
|
||||
void onEnterForeground();
|
||||
|
||||
/// Called when app enters background
|
||||
void onEnterBackground();
|
||||
|
||||
/// Dispose all resources
|
||||
Future<void> dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
// Repository Interface(Domain)
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:networks_sdk/src/data/dto/api_requestable.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/network_callbacks.dart';
|
||||
|
||||
/// 网络层 Repository 接口
|
||||
///
|
||||
/// 定义两种请求入口:
|
||||
/// - [executeRequest] — 统一请求入口(标准 / Upload / 流式)
|
||||
/// - [executeDownload] — 带进度的文件下载(支持断点续传)
|
||||
abstract class NetworksSdkRepository {
|
||||
Future<String?> platformVersion();
|
||||
|
||||
void initialize(ApiConfig apiConfig);
|
||||
|
||||
Future<T?> executeRequest<T>(ApiRequestable<T> request);
|
||||
Future<T?> executeRequest<T>(
|
||||
ApiRequestable<T> request, {
|
||||
CancelToken? cancelToken,
|
||||
});
|
||||
|
||||
/// 文件下载(支持进度回调和断点续传)
|
||||
Future<void> executeDownload({
|
||||
required String url,
|
||||
required String savePath,
|
||||
OnDownloadProgress? onProgress,
|
||||
CancelToken? cancelToken,
|
||||
bool resume = false,
|
||||
Map<String, String>? headers,
|
||||
});
|
||||
}
|
||||
@@ -4,35 +4,91 @@ import 'package:networks_sdk/src/annotations/api_request.dart';
|
||||
import 'package:source_gen/source_gen.dart';
|
||||
import 'package:build/build.dart';
|
||||
|
||||
/// @JsonKey 检测器(用于读取字段的 JSON 键名映射)
|
||||
const _jsonKeyChecker = TypeChecker.fromUrl(
|
||||
'package:json_annotation/src/json_key.dart#JsonKey',
|
||||
);
|
||||
|
||||
/// @ApiRequest 代码生成器
|
||||
///
|
||||
/// 为标注了 `@ApiRequest` 的类自动生成 mixin,提供:
|
||||
/// - `path`, `method`, `requestType`, `includeToken` 协议实现
|
||||
/// - 自动注册响应类型的 `fromJson`(在 `parameters` getter 中触发,
|
||||
/// 保证首次请求前完成注册,无需手动调用 `registerApiResponses()`)
|
||||
/// - `toJson()` — 从类的声明字段自动生成,只序列化自身字段,
|
||||
/// 不含 ApiRequestable 的继承属性,避免递归
|
||||
/// - 自动注册响应类型的 `fromJson`(在 `parameters` getter 中触发)
|
||||
///
|
||||
/// 生成的 mixin 命名规则:`_$<ClassName>Api`
|
||||
/// 支持 `@JsonKey(name: '...')` 字段重命名。
|
||||
/// 如有 `@JsonKey(includeToJson: false)` 则跳过该字段。
|
||||
///
|
||||
/// ## 使用模式(有响应数据)
|
||||
///
|
||||
/// 示例输出:
|
||||
/// ```dart
|
||||
/// mixin _$LoginRequestApi on ApiRequestable<LoginData> {
|
||||
/// @ApiRequest(
|
||||
/// path: ApiPaths.authLogin,
|
||||
/// method: HttpMethod.post,
|
||||
/// responseType: LoginResponse,
|
||||
/// requestType: ApiRequestType.login,
|
||||
/// )
|
||||
/// class LoginRequest extends ApiRequestable<LoginResponse>
|
||||
/// with _$LoginRequestApi {
|
||||
/// final String email;
|
||||
/// final String password;
|
||||
///
|
||||
/// LoginRequest({required this.email, required this.password});
|
||||
/// // 完毕!toJson / path / method / fromJson 注册全部由 mixin 自动生成
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// ## 使用模式(无响应数据)
|
||||
///
|
||||
/// ```dart
|
||||
/// @ApiRequest(
|
||||
/// path: ApiPaths.authLogout,
|
||||
/// method: HttpMethod.post,
|
||||
/// )
|
||||
/// class LogoutRequest extends ApiRequestable<void>
|
||||
/// with _$LogoutRequestApi {
|
||||
/// LogoutRequest();
|
||||
/// // responseType 省略 → mixin 跳过 fromJson 注册
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// ## mixin 命名规则
|
||||
///
|
||||
/// `_$<ClassName>Api`
|
||||
///
|
||||
/// ## 生成示例(有响应数据)
|
||||
///
|
||||
/// ```dart
|
||||
/// mixin _$LoginRequestApi on ApiRequestable<LoginResponse> {
|
||||
/// @override String get path => '/auth/login';
|
||||
/// @override HttpMethod get method => HttpMethod.post;
|
||||
/// @override ApiRequestType get requestType => ApiRequestType.login;
|
||||
/// @override bool get includeToken => false;
|
||||
/// @override
|
||||
/// Map<String, dynamic> toJson() => <String, dynamic>{
|
||||
/// 'email': (this as LoginRequest).email,
|
||||
/// 'password': (this as LoginRequest).password,
|
||||
/// };
|
||||
/// @override
|
||||
/// Map<String, dynamic>? get parameters {
|
||||
/// registerResponse<LoginData>(LoginData.fromJson);
|
||||
/// registerResponse<LoginResponse>(_$LoginResponseFromJson);
|
||||
/// return super.parameters;
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
class ApiRequestGenerator extends GeneratorForAnnotation<ApiRequest>
|
||||
{
|
||||
///
|
||||
/// ## Upload 等特殊请求
|
||||
///
|
||||
/// 如需自定义 toJson(如 upload 返回空 map),在类中 override 即可,
|
||||
/// 类的 override 优先于 mixin。
|
||||
class ApiRequestGenerator extends GeneratorForAnnotation<ApiRequest> {
|
||||
@override
|
||||
String generateForAnnotatedElement(Element element, ConstantReader annotation, BuildStep buildStep,)
|
||||
{
|
||||
String generateForAnnotatedElement(
|
||||
Element element,
|
||||
ConstantReader annotation,
|
||||
BuildStep buildStep,
|
||||
) {
|
||||
if (element is! ClassElement) {
|
||||
throw InvalidGenerationSourceError(
|
||||
'@ApiRequest can only be applied to classes.',
|
||||
@@ -40,7 +96,7 @@ class ApiRequestGenerator extends GeneratorForAnnotation<ApiRequest>
|
||||
);
|
||||
}
|
||||
|
||||
final className = element.name;
|
||||
final className = element.name!;
|
||||
final path = annotation.read('path').stringValue;
|
||||
|
||||
// 读取 HttpMethod 枚举值
|
||||
@@ -49,9 +105,13 @@ class ApiRequestGenerator extends GeneratorForAnnotation<ApiRequest>
|
||||
'post',
|
||||
);
|
||||
|
||||
// 读取 responseType(用于泛型绑定 + 自动注册 fromJson)
|
||||
final responseType = annotation.read('responseType').typeValue;
|
||||
final responseTypeName = responseType.getDisplayString();
|
||||
// 读取 responseType(可选:null 表示 void 响应,无需注册 fromJson)
|
||||
final responseTypePeek = annotation.peek('responseType');
|
||||
final bool hasResponseType =
|
||||
responseTypePeek != null && !responseTypePeek.isNull;
|
||||
final String responseTypeName = hasResponseType
|
||||
? responseTypePeek.typeValue.getDisplayString()
|
||||
: 'void';
|
||||
|
||||
// 读取 ApiRequestType 枚举值
|
||||
final requestTypeName = _readEnumName(
|
||||
@@ -68,6 +128,17 @@ class ApiRequestGenerator extends GeneratorForAnnotation<ApiRequest>
|
||||
includeToken = requestTypeName != 'login';
|
||||
}
|
||||
|
||||
// 从类的声明字段生成 toJson()
|
||||
final toJsonBody = _buildToJsonBody(element, className);
|
||||
|
||||
// 有响应类型:parameters getter 中注册 fromJson(使用生成的私有函数)
|
||||
// ApiResponseGenerator 在同一 .g.dart 中生成 _$XFromJson,同 library 可访问
|
||||
// 无响应类型(void):跳过注册,直接返回 super.parameters
|
||||
final parametersBody = hasResponseType
|
||||
? ''' registerResponse<$responseTypeName>(_\$${responseTypeName}FromJson);
|
||||
return super.parameters;'''
|
||||
: ' return super.parameters;';
|
||||
|
||||
return '''
|
||||
/// Generated by @ApiRequest for [$className]
|
||||
mixin _\$${className}Api on ApiRequestable<$responseTypeName> {
|
||||
@@ -80,14 +151,55 @@ mixin _\$${className}Api on ApiRequestable<$responseTypeName> {
|
||||
@override
|
||||
bool get includeToken => $includeToken;
|
||||
@override
|
||||
Map<String, dynamic> toJson() => $toJsonBody;
|
||||
@override
|
||||
Map<String, dynamic>? get parameters {
|
||||
registerResponse<$responseTypeName>($responseTypeName.fromJson);
|
||||
return super.parameters;
|
||||
$parametersBody
|
||||
}
|
||||
}
|
||||
''';
|
||||
}
|
||||
|
||||
/// 从类的声明字段构建 toJson() 方法体
|
||||
///
|
||||
/// 只读取类自身声明的实例字段(非 static、非 synthetic),
|
||||
/// 不含继承自 ApiRequestable 的属性,避免递归。
|
||||
/// 支持 @JsonKey(name: '...') 字段重命名,
|
||||
/// 以及 @JsonKey(includeToJson: false) 跳过字段。
|
||||
String _buildToJsonBody(ClassElement element, String className) {
|
||||
final fields = element.fields
|
||||
.where((f) => !f.isStatic && !f.isSynthetic)
|
||||
.toList();
|
||||
|
||||
if (fields.isEmpty) {
|
||||
return '<String, dynamic>{}';
|
||||
}
|
||||
|
||||
final entries = <String>[];
|
||||
for (final field in fields) {
|
||||
// 检查 @JsonKey 注解
|
||||
final jsonKeyAnnotation = _jsonKeyChecker.firstAnnotationOfExact(field);
|
||||
|
||||
// @JsonKey(includeToJson: false) → 跳过
|
||||
final includeToJson = jsonKeyAnnotation
|
||||
?.getField('includeToJson')
|
||||
?.toBoolValue();
|
||||
if (includeToJson == false) continue;
|
||||
|
||||
// JSON 键名:@JsonKey(name: '...') 或字段名
|
||||
final jsonName =
|
||||
jsonKeyAnnotation?.getField('name')?.toStringValue() ?? field.name;
|
||||
|
||||
entries.add("'$jsonName': (this as $className).${field.name}");
|
||||
}
|
||||
|
||||
if (entries.isEmpty) {
|
||||
return '<String, dynamic>{}';
|
||||
}
|
||||
|
||||
return '<String, dynamic>{${entries.join(', ')}}';
|
||||
}
|
||||
|
||||
/// 从 DartObject 提取枚举常量名称
|
||||
String _readEnumName(dynamic dartObject, String defaultValue) {
|
||||
final index = dartObject.getField('index')?.toIntValue();
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import 'package:analyzer/dart/element/element.dart';
|
||||
import 'package:analyzer/dart/element/nullability_suffix.dart';
|
||||
import 'package:analyzer/dart/element/type.dart';
|
||||
import 'package:source_gen/source_gen.dart';
|
||||
import 'package:build/build.dart';
|
||||
|
||||
/// @JsonKey 检测器(与 ApiRequestGenerator 共用相同策略)
|
||||
const _jsonKeyChecker = TypeChecker.fromUrl(
|
||||
'package:json_annotation/src/json_key.dart#JsonKey',
|
||||
);
|
||||
|
||||
/// @ApiRequest 检测器(复用,用于找到所有 responseType 入口)
|
||||
const _apiRequestChecker = TypeChecker.fromUrl(
|
||||
'package:networks_sdk/src/annotations/api_request.dart#ApiRequest',
|
||||
);
|
||||
|
||||
/// Response 反序列化代码生成器
|
||||
///
|
||||
/// 从同一 library 中的 `@ApiRequest(responseType: T)` 声明出发,
|
||||
/// 自动生成 `_$TFromJson` 私有函数,无需在 Response 类上添加任何注解。
|
||||
///
|
||||
/// ## 设计原理
|
||||
///
|
||||
/// App 层只需标注 Request 类:
|
||||
/// ```dart
|
||||
/// @ApiRequest(path: '...', responseType: LoginResponse)
|
||||
/// class LoginRequest extends ApiRequestable<LoginResponse>
|
||||
/// with _$LoginRequestApi { ... }
|
||||
/// ```
|
||||
///
|
||||
/// 生成器从 `responseType` 出发,递归找到所有嵌套自定义类型,
|
||||
/// 在同一 `.g.dart` 中生成所有 `_$XFromJson` 私有函数。
|
||||
///
|
||||
/// Response 类体本身是纯 Dart 类,**无需任何注解,无需 factory fromJson**:
|
||||
/// ```dart
|
||||
/// class LoginResponse {
|
||||
/// @JsonKey(name: 'access_token')
|
||||
/// final String accessToken;
|
||||
/// final LoginProfile profile;
|
||||
/// // 完毕!fromJson 由生成器自动提供
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// ## 生成示例
|
||||
///
|
||||
/// ```dart
|
||||
/// LoginProfile _$LoginProfileFromJson(Map<String, dynamic> json) {
|
||||
/// return LoginProfile(
|
||||
/// uid: json['uid'] as int,
|
||||
/// uuid: json['uuid'] as String,
|
||||
/// );
|
||||
/// }
|
||||
///
|
||||
/// LoginResponse _$LoginResponseFromJson(Map<String, dynamic> json) {
|
||||
/// return LoginResponse(
|
||||
/// accessToken: json['access_token'] as String,
|
||||
/// profile: _$LoginProfileFromJson(json['profile'] as Map<String, dynamic>),
|
||||
/// );
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// ## 支持的字段类型
|
||||
///
|
||||
/// - 基础类型(String / int / bool / double / num)
|
||||
/// - 可空类型(String? / int? / 自定义类?)
|
||||
/// - 嵌套对象(自动递归,在同一 `.g.dart` 中生成被依赖类的函数)
|
||||
/// - `@JsonKey(name: '...')` 字段重命名
|
||||
/// - `@JsonKey(includeFromJson: false)` 跳过字段
|
||||
class ApiResponseGenerator extends Generator {
|
||||
@override
|
||||
String generate(LibraryReader library, BuildStep buildStep) {
|
||||
final buffer = StringBuffer();
|
||||
// 跟踪已生成的类,避免同一 library 中多个 Request 引用同一 Response 类时重复生成
|
||||
final generated = <String>{};
|
||||
|
||||
for (final annotated in library.annotatedWith(_apiRequestChecker)) {
|
||||
final annotation = annotated.annotation;
|
||||
final responseTypePeek = annotation.peek('responseType');
|
||||
if (responseTypePeek == null || responseTypePeek.isNull) continue;
|
||||
|
||||
final responseType = responseTypePeek.typeValue;
|
||||
if (responseType is! InterfaceType) continue;
|
||||
|
||||
final responseClass = responseType.element;
|
||||
if (responseClass is! ClassElement) continue;
|
||||
|
||||
// 只生成属于当前 library 的类(跨包类型跳过)
|
||||
if (responseClass.library != library.element) continue;
|
||||
|
||||
_generateRecursive(responseClass, library, generated, buffer);
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// 递归生成:先生成被依赖的嵌套类型,再生成当前类型(保证引用顺序)
|
||||
void _generateRecursive(
|
||||
ClassElement element,
|
||||
LibraryReader library,
|
||||
Set<String> generated,
|
||||
StringBuffer buffer,
|
||||
) {
|
||||
final typeName = element.name!;
|
||||
if (generated.contains(typeName)) return;
|
||||
generated.add(typeName);
|
||||
|
||||
// 先递归处理嵌套的自定义类型
|
||||
// InterfaceType 的 element 在 nullable 和非 nullable 情况下指向同一个 ClassElement
|
||||
for (final field in element.fields.where(
|
||||
(f) => !f.isStatic && !f.isSynthetic,
|
||||
)) {
|
||||
final fieldType = field.type;
|
||||
if (fieldType is InterfaceType) {
|
||||
final fieldClass = fieldType.element;
|
||||
if (fieldClass is ClassElement &&
|
||||
fieldClass.library == library.element &&
|
||||
!_isPrimitive(fieldClass.name!)) {
|
||||
_generateRecursive(fieldClass, library, generated, buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final params = _buildConstructorParams(element);
|
||||
buffer.write('''
|
||||
/// Generated by ApiResponseGenerator for [$typeName]
|
||||
$typeName _\$${typeName}FromJson(Map<String, dynamic> json) {
|
||||
return $typeName(
|
||||
$params );
|
||||
}
|
||||
''');
|
||||
}
|
||||
|
||||
/// 判断是否为不需要递归生成的基础类型
|
||||
bool _isPrimitive(String typeName) {
|
||||
const primitives = {
|
||||
'String',
|
||||
'int',
|
||||
'double',
|
||||
'bool',
|
||||
'num',
|
||||
'dynamic',
|
||||
'Object',
|
||||
'List',
|
||||
'Map',
|
||||
'Set',
|
||||
'Iterable',
|
||||
};
|
||||
return primitives.contains(typeName);
|
||||
}
|
||||
|
||||
/// 从类的字段生成构造函数参数列表
|
||||
String _buildConstructorParams(ClassElement element) {
|
||||
final fields = element.fields
|
||||
.where((f) => !f.isStatic && !f.isSynthetic)
|
||||
.toList();
|
||||
|
||||
final lines = <String>[];
|
||||
for (final field in fields) {
|
||||
final jsonKeyAnnotation = _jsonKeyChecker.firstAnnotationOfExact(field);
|
||||
|
||||
// @JsonKey(includeFromJson: false) → 跳过
|
||||
final includeFromJson = jsonKeyAnnotation
|
||||
?.getField('includeFromJson')
|
||||
?.toBoolValue();
|
||||
if (includeFromJson == false) continue;
|
||||
|
||||
final fieldName = field.name!;
|
||||
final jsonName =
|
||||
jsonKeyAnnotation?.getField('name')?.toStringValue() ?? fieldName;
|
||||
|
||||
final type = field.type;
|
||||
final isNullable = type.nullabilitySuffix == NullabilitySuffix.question;
|
||||
final expr = _castExpression(type, jsonName, isNullable);
|
||||
|
||||
lines.add(' $fieldName: $expr,');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/// 根据字段类型生成 JSON 取值表达式
|
||||
String _castExpression(DartType type, String jsonKey, bool isNullable) {
|
||||
final q = isNullable ? '?' : '';
|
||||
final access = "json['$jsonKey']";
|
||||
|
||||
// 基础类型:直接 as 转换
|
||||
if (type.isDartCoreString) return '$access as String$q';
|
||||
if (type.isDartCoreInt) return '$access as int$q';
|
||||
if (type.isDartCoreBool) return '$access as bool$q';
|
||||
if (type.isDartCoreDouble) return '$access as double$q';
|
||||
if (type.isDartCoreNum) return '$access as num$q';
|
||||
|
||||
// 嵌套对象:调用同一 part 文件中生成的 _$TypeFromJson 私有函数
|
||||
if (type is InterfaceType) {
|
||||
final typeName = type.element.name!;
|
||||
if (isNullable) {
|
||||
return '$access == null ? null : _\$${typeName}FromJson($access as Map<String, dynamic>)';
|
||||
}
|
||||
return '_\$${typeName}FromJson($access as Map<String, dynamic>)';
|
||||
}
|
||||
|
||||
// 兜底
|
||||
return '$access as dynamic';
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,16 @@ import 'package:build/build.dart';
|
||||
import 'package:source_gen/source_gen.dart';
|
||||
|
||||
import 'api_request_generator.dart';
|
||||
import 'api_response_generator.dart';
|
||||
|
||||
/// @ApiRequest 代码生成器入口
|
||||
/// @ApiRequest / @ApiResponse 代码生成器入口
|
||||
///
|
||||
/// 在 `build.yaml` 中注册此 builder,配合 `build_runner` 使用。
|
||||
/// 生成的代码通过 `SharedPartBuilder` 合并到 `.g.dart` 文件中,
|
||||
/// 与 `json_serializable` 等生成器共存。
|
||||
Builder apiRequestBuilder(BuilderOptions options) =>
|
||||
SharedPartBuilder([ApiRequestGenerator()], 'api_request');
|
||||
/// 生成的代码通过 `SharedPartBuilder` 合并到 `.g.dart` 文件中。
|
||||
///
|
||||
/// - `ApiRequestGenerator`:为 `@ApiRequest` 生成 Request mixin(toJson + path/method 等)
|
||||
/// - `ApiResponseGenerator`:从 `@ApiRequest(responseType: T)` 推导,生成 Response 的所有 `_$XFromJson` 函数
|
||||
Builder apiRequestBuilder(BuilderOptions options) => SharedPartBuilder([
|
||||
ApiRequestGenerator(),
|
||||
ApiResponseGenerator(),
|
||||
], 'api_request');
|
||||
|
||||
@@ -1,92 +1,75 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:networks_sdk/src/domain/entities/socket_connection_state.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/socket_error.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/networks_sdk_wiring.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/socket_config.dart';
|
||||
|
||||
/// Messaging API for real-time communication
|
||||
/// 实时通信公开接口
|
||||
///
|
||||
/// This abstract class provides a technology-agnostic interface for
|
||||
/// real-time messaging. The actual implementation may use WebSocket
|
||||
/// or other transport mechanisms.
|
||||
/// 底层基于 WebSocket,支持 JSON / 字符串 / 二进制消息、
|
||||
/// 自动重连(含无限重连)、Token 热更新、消息加密/解密钩子。
|
||||
///
|
||||
/// ## Usage
|
||||
/// ## 使用方式
|
||||
///
|
||||
/// ```dart
|
||||
/// final messaging = NetworksMessagingApi();
|
||||
/// await messaging.initialize(SocketConfig(...));
|
||||
///
|
||||
/// // Connect to messaging server
|
||||
/// await messaging.connect('wss://api.example.com/ws', token: 'xxx');
|
||||
///
|
||||
/// // Listen for messages
|
||||
/// messaging.messageStream.listen((msg) => print(msg));
|
||||
///
|
||||
/// // Send messages
|
||||
/// await messaging.send({'type': 'chat', 'data': {...}});
|
||||
///
|
||||
/// // Handle connection state
|
||||
/// messaging.connectionStateStream.listen((state) => ...);
|
||||
/// // Token 热更新(不断连)
|
||||
/// messaging.updateToken('new_token');
|
||||
///
|
||||
/// // Handle errors
|
||||
/// messaging.errorStream.listen((error) => ...);
|
||||
/// // 发送二进制
|
||||
/// await messaging.sendBytes(Uint8List.fromList([0x01, 0x02]));
|
||||
///
|
||||
/// // Lifecycle management
|
||||
/// messaging.onEnterForeground();
|
||||
/// messaging.onEnterBackground();
|
||||
///
|
||||
/// // Cleanup
|
||||
/// await messaging.disconnect();
|
||||
/// await messaging.dispose();
|
||||
/// ```
|
||||
abstract class NetworksMessagingApi
|
||||
{
|
||||
abstract class NetworksMessagingApi {
|
||||
factory NetworksMessagingApi() => NetworksSdkWiring.buildMessagingApi();
|
||||
|
||||
/// Initialize the messaging service with configuration
|
||||
void initialize(SocketConfig config);
|
||||
|
||||
/// Connect to the messaging server
|
||||
///
|
||||
/// [url] - WebSocket URL (e.g., 'wss://api.example.com/ws')
|
||||
/// [token] - Optional authentication token
|
||||
Future<bool> connect(String url, {String? token});
|
||||
|
||||
/// Disconnect from the messaging server
|
||||
///
|
||||
/// Manual disconnect does not trigger auto-reconnect
|
||||
Future<void> disconnect();
|
||||
|
||||
/// Check if currently connected
|
||||
bool get isConnected;
|
||||
|
||||
/// Current connection state
|
||||
SocketConnectionState get connectionState;
|
||||
|
||||
/// Send a JSON message
|
||||
/// Token 热更新(不断开连接)
|
||||
///
|
||||
/// 仅更新内部 token,下次重连自动使用新 token。
|
||||
void updateToken(String token);
|
||||
|
||||
Future<bool> send(Map<String, dynamic> message);
|
||||
|
||||
/// Send a raw string message
|
||||
Future<bool> sendString(String message);
|
||||
|
||||
/// Stream of incoming parsed JSON messages
|
||||
/// 发送二进制数据
|
||||
Future<bool> sendBytes(List<int> bytes);
|
||||
|
||||
Stream<Map<String, dynamic>> get messageStream;
|
||||
|
||||
/// Stream of raw string messages (including failed JSON parses)
|
||||
Stream<String> get rawMessageStream;
|
||||
|
||||
/// Stream of connection state changes
|
||||
/// 二进制消息流
|
||||
Stream<Uint8List> get binaryMessageStream;
|
||||
|
||||
Stream<SocketConnectionState> get connectionStateStream;
|
||||
|
||||
/// Stream of errors
|
||||
Stream<SocketError> get errorStream;
|
||||
|
||||
/// Called when app enters foreground
|
||||
void onEnterForeground();
|
||||
|
||||
/// Called when app enters background
|
||||
void onEnterBackground();
|
||||
|
||||
/// Dispose all resources
|
||||
Future<void> dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,69 @@
|
||||
|
||||
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:networks_sdk/src/data/dto/api_requestable.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/network_callbacks.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/networks_sdk_wiring.dart';
|
||||
|
||||
|
||||
/// SDK API
|
||||
abstract class NetworksSdkApi
|
||||
{
|
||||
/// Networks SDK 公开接口
|
||||
///
|
||||
/// 提供两种请求入口:
|
||||
/// - [executeRequest] — 统一请求入口(标准 / Upload / 流式)
|
||||
/// - [executeDownload] — 带进度的文件下载(支持断点续传)
|
||||
///
|
||||
/// 流式请求(SSE)也走 [executeRequest],由业务 Request 类 override
|
||||
/// `decodeResponse` 处理 SSE 解析。SDK 根据 `requestType == stream`
|
||||
/// 自动切换响应类型。
|
||||
///
|
||||
/// 使用方式:
|
||||
/// ```dart
|
||||
/// final sdk = NetworksSdkApi();
|
||||
/// sdk.initialize(apiConfig);
|
||||
///
|
||||
/// // 标准请求
|
||||
/// final data = await sdk.executeRequest(LoginRequest(...));
|
||||
///
|
||||
/// // 流式请求(SSE)— 同一入口,Request 类 override decodeResponse
|
||||
/// final result = await sdk.executeRequest(VoiceTranscribeRequest(...));
|
||||
///
|
||||
/// // 文件下载
|
||||
/// await sdk.executeDownload(
|
||||
/// url: '/files/report.pdf',
|
||||
/// savePath: '/tmp/report.pdf',
|
||||
/// onProgress: (received, total) => print('$received / $total'),
|
||||
/// );
|
||||
/// ```
|
||||
abstract class NetworksSdkApi {
|
||||
factory NetworksSdkApi() => NetworksSdkWiring.build();
|
||||
|
||||
Future<String?> platformVersion();
|
||||
|
||||
void initialize(ApiConfig aApiConfig);
|
||||
void initialize(ApiConfig apiConfig);
|
||||
|
||||
Future<T?> executeRequest<T>(ApiRequestable<T> request);
|
||||
/// 执行 API 请求 — 统一入口
|
||||
///
|
||||
/// 支持标准请求、登录、上传、流式(SSE),由 `request.requestType` 控制。
|
||||
/// 流式请求由业务 Request 类 override `decodeResponse` 处理 SSE 解析。
|
||||
///
|
||||
/// [cancelToken] — 可选,用于取消正在进行的请求
|
||||
Future<T?> executeRequest<T>(
|
||||
ApiRequestable<T> request, {
|
||||
CancelToken? cancelToken,
|
||||
});
|
||||
|
||||
/// 下载文件到本地路径
|
||||
///
|
||||
/// [url] — 下载 URL(完整路径或相对路径)
|
||||
/// [savePath] — 本地保存路径
|
||||
/// [onProgress] — 下载进度回调
|
||||
/// [cancelToken] — 取消令牌
|
||||
/// [resume] — 是否断点续传
|
||||
/// [headers] — 额外请求头
|
||||
Future<void> executeDownload({
|
||||
required String url,
|
||||
required String savePath,
|
||||
OnDownloadProgress? onProgress,
|
||||
CancelToken? cancelToken,
|
||||
bool resume = false,
|
||||
Map<String, String>? headers,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import 'network_callbacks.dart';
|
||||
|
||||
/// API 配置
|
||||
@@ -13,12 +12,22 @@ class ApiConfig {
|
||||
/// 平台相关 headers(App 层注入:version、platform、channel 等)
|
||||
Map<String, String> platformHeaders;
|
||||
|
||||
// ── 认证回调 ──
|
||||
|
||||
/// Token 过期时的刷新回调
|
||||
final OnTokenRefresh? onTokenRefresh;
|
||||
|
||||
/// 需要强制登出时的回调
|
||||
final OnForceLogout? onForceLogout;
|
||||
|
||||
/// Token 更新后的通知回调
|
||||
///
|
||||
/// 在 [updateToken] 被调用且新 token 非空时触发。
|
||||
/// App 层用于同步 token 到 WebSocket 等其他模块。
|
||||
final void Function(String newToken)? onTokenUpdated;
|
||||
|
||||
// ── 基础回调 ──
|
||||
|
||||
/// 日志输出回调(不设置则不输出日志)
|
||||
final OnLog? onLog;
|
||||
|
||||
@@ -29,12 +38,39 @@ class ApiConfig {
|
||||
/// 返回 false 则直接抛 [ApiError.noNetworkConnection],不走网络。
|
||||
final OnCheckNetworkAvailable? onCheckNetworkAvailable;
|
||||
|
||||
// ── 加密回调(预留给 cipher_guard_sdk)──
|
||||
|
||||
/// 请求体加密回调,null 时不加密
|
||||
final OnEncryptRequest? onEncryptRequest;
|
||||
|
||||
/// 响应体解密回调,null 时不解密
|
||||
final OnDecryptResponse? onDecryptResponse;
|
||||
|
||||
// ── 业务错误回调 ──
|
||||
|
||||
/// 业务错误拦截回调
|
||||
///
|
||||
/// 在 token 过期 / 强制登出判断之后执行。
|
||||
/// 返回 true = App 层已处理,SDK 正常传递响应;
|
||||
/// 返回 false = 未处理,SDK 继续正常流程。
|
||||
final OnBusinessError? onBusinessError;
|
||||
|
||||
/// 响应变换回调
|
||||
///
|
||||
/// 在 `executeRequest` 解码前调用,App 层可以统一解包
|
||||
/// `{ code, data, message }` 结构。返回 null 表示不变换。
|
||||
final OnTransformResponse? onTransformResponse;
|
||||
|
||||
// ── 错误码集合 ──
|
||||
|
||||
/// App 层定义的 Token 过期错误码集合
|
||||
final Set<int> tokenExpiredCodes;
|
||||
|
||||
/// App 层定义的强制登出错误码集合
|
||||
final Set<int> forceLogoutCodes;
|
||||
|
||||
// ── 重试配置 ──
|
||||
|
||||
/// 瞬态错误最大重试次数(5xx / 超时 / 连接失败)
|
||||
///
|
||||
/// 0 = 不重试(默认),设为 3 启用重试。
|
||||
@@ -46,18 +82,50 @@ class ApiConfig {
|
||||
/// 实际延迟 = min(baseDelay * 2^attempt, 30s) + jitter
|
||||
final Duration retryBaseDelay;
|
||||
|
||||
// ── Token 刷新配置 ──
|
||||
|
||||
/// Token 刷新超时时间,防止 onTokenRefresh 卡住导致请求永远阻塞
|
||||
final Duration tokenRefreshTimeout;
|
||||
|
||||
/// Token 刷新时间窗口:刷新成功后此时间内再次收到过期码直接返回成功,
|
||||
/// 避免服务端同步延迟导致的误判
|
||||
final Duration tokenReuseWindow;
|
||||
|
||||
// ── 主动刷新配置 ──
|
||||
|
||||
/// Token 过期时间解析回调
|
||||
///
|
||||
/// App 层解析 JWT `exp` claim,用于主动刷新判断。
|
||||
/// 未注入时不启用主动刷新。
|
||||
final OnGetTokenExpiry? onGetTokenExpiry;
|
||||
|
||||
/// 主动刷新阈值:距过期不足此时间时提前刷新
|
||||
///
|
||||
/// 默认 1 小时。WebSocket 重连前、App 回前台时
|
||||
/// 自动检查并刷新即将过期的 token,避免带过期 token 发起请求。
|
||||
final Duration proactiveRefreshThreshold;
|
||||
|
||||
ApiConfig({
|
||||
required this.baseURL,
|
||||
this.token,
|
||||
this.platformHeaders = const {},
|
||||
this.onTokenRefresh,
|
||||
this.onForceLogout,
|
||||
this.onTokenUpdated,
|
||||
this.onLog,
|
||||
this.onCheckNetworkAvailable,
|
||||
this.onEncryptRequest,
|
||||
this.onDecryptResponse,
|
||||
this.onBusinessError,
|
||||
this.onTransformResponse,
|
||||
this.tokenExpiredCodes = const {},
|
||||
this.forceLogoutCodes = const {},
|
||||
this.maxRetries = 0,
|
||||
this.retryBaseDelay = const Duration(seconds: 1),
|
||||
this.tokenRefreshTimeout = const Duration(seconds: 10),
|
||||
this.tokenReuseWindow = const Duration(seconds: 3),
|
||||
this.onGetTokenExpiry,
|
||||
this.proactiveRefreshThreshold = const Duration(hours: 1),
|
||||
});
|
||||
|
||||
/// 构建默认 headers
|
||||
@@ -70,6 +138,8 @@ class ApiConfig {
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Accept': 'application/json',
|
||||
'Keep-Alive': 'timeout=60',
|
||||
// Unix 时间戳(秒),整数值,非格式化日期字符串
|
||||
'Timestamp': '${DateTime.now().millisecondsSinceEpoch ~/ 1000}',
|
||||
'APP-Request-ID': _generateRequestId(),
|
||||
};
|
||||
@@ -91,8 +161,13 @@ class ApiConfig {
|
||||
}
|
||||
|
||||
/// 更新 token
|
||||
///
|
||||
/// 同时触发 [onTokenUpdated] 通知其他模块(如 WebSocket)同步 token。
|
||||
void updateToken(String? newToken) {
|
||||
token = newToken;
|
||||
if (newToken != null && newToken.isNotEmpty) {
|
||||
onTokenUpdated?.call(newToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新 base URL
|
||||
|
||||
@@ -1,13 +1,113 @@
|
||||
/// 网络层回调类型定义,由 App 层注入 SDK,避免 SDK 直接依赖外部实现。
|
||||
library;
|
||||
|
||||
import 'package:networks_sdk/src/domain/entities/encrypted_request.dart';
|
||||
|
||||
// ── 认证 ──
|
||||
|
||||
/// Token 刷新回调,返回新 token;返回 null 表示刷新失败
|
||||
typedef OnTokenRefresh = Future<String?> Function();
|
||||
|
||||
/// 強制登出回調
|
||||
/// 强制登出回调
|
||||
typedef OnForceLogout = void Function();
|
||||
|
||||
/// 日誌輸出回調
|
||||
// ── Token 生命周期 ──
|
||||
|
||||
/// 获取 token 过期时间
|
||||
///
|
||||
/// App 层解析 JWT 的 `exp` claim 返回过期时间。
|
||||
/// 返回 null 表示无法解析(非 JWT 或格式错误)。
|
||||
typedef OnGetTokenExpiry = DateTime? Function(String token);
|
||||
|
||||
// ── 基础 ──
|
||||
|
||||
/// 日志输出回调
|
||||
typedef OnLog = void Function(String message, {String? tag});
|
||||
|
||||
/// 網路可用性查詢(App 層注入,SDK 在請求前調用)
|
||||
/// 网络可用性查询(App 层注入,SDK 在请求前调用)
|
||||
typedef OnCheckNetworkAvailable = Future<bool> Function();
|
||||
|
||||
// ── 加密(预留给 cipher_guard_sdk)──
|
||||
|
||||
/// HTTP 请求加密回调
|
||||
///
|
||||
/// 接收原始路径、headers、请求体,返回 [EncryptedRequest]。
|
||||
/// 拦截器根据返回值中的非 null 字段覆盖原始请求。
|
||||
///
|
||||
/// 参数说明:
|
||||
/// - [path] — 原始请求路径(如 `/api/v1/auth/login`)
|
||||
/// - [headers] — 当前请求的全部 headers(含 token、platform headers 等)
|
||||
/// - [body] — 原始请求体(可能是 Map、String、null 等)
|
||||
///
|
||||
/// App 层实现示例(X25519 + AES-256-CBC 模式):
|
||||
/// - 加密 path → hex 编码 → 替换路径
|
||||
/// - 加密 body → base64 编码 → 替换请求体
|
||||
/// - 加密 token → 放入 X-Token header
|
||||
/// - Ed25519 签名 → 放入 X-Signature header
|
||||
/// - Content-Type → text/plain
|
||||
typedef OnEncryptRequest =
|
||||
Future<EncryptedRequest> Function(
|
||||
String path,
|
||||
Map<String, String> headers,
|
||||
Object? body,
|
||||
);
|
||||
|
||||
/// HTTP 响应解密回调
|
||||
///
|
||||
/// 输入是原始响应数据(加密后可能是 String、`List<int>`、或 Map),
|
||||
/// 返回解密后的 Map 供业务层使用。
|
||||
///
|
||||
/// [responseData] 的实际类型取决于服务端响应格式:
|
||||
/// - 加密模式下通常是 base64 字符串
|
||||
/// - 非加密模式下是 `Map<String, dynamic>`(拦截器会自动跳过,不调用此回调)
|
||||
///
|
||||
/// 实现时建议做类型判断兜底,应对非预期的响应格式:
|
||||
/// ```dart
|
||||
/// onDecryptResponse: (data) async {
|
||||
/// if (data is! String) throw FormatException('Expected String, got ${data.runtimeType}');
|
||||
/// return jsonDecode(aesDecrypt(data));
|
||||
/// }
|
||||
/// ```
|
||||
typedef OnDecryptResponse =
|
||||
Future<Map<String, dynamic>> Function(Object responseData);
|
||||
|
||||
// ── 业务错误 ──
|
||||
|
||||
/// 业务错误拦截回调
|
||||
///
|
||||
/// App 层统一处理特定错误码,返回 true = 已处理(SDK 不再抛错),
|
||||
/// 返回 false = 未处理(SDK 继续正常流程)。
|
||||
typedef OnBusinessError = bool Function(int code, String message, String path);
|
||||
|
||||
/// 响应变换回调
|
||||
///
|
||||
/// App 层自定义响应解包逻辑(如统一解包 `{ code, data, message }` 结构)。
|
||||
/// 返回 null 表示不变换,使用原始响应。
|
||||
typedef OnTransformResponse =
|
||||
Map<String, dynamic>? Function(Map<String, dynamic> raw);
|
||||
|
||||
// ── 下载 ──
|
||||
|
||||
/// 下载进度回调
|
||||
typedef OnDownloadProgress = void Function(int received, int total);
|
||||
|
||||
// ── WebSocket 加密(预留给 cipher_guard_sdk)──
|
||||
|
||||
/// WebSocket 连接 URL 构建回调
|
||||
///
|
||||
/// 建立连接前调用,接收原始 URL 和 token,返回最终的连接 URL 字符串。
|
||||
/// WS 握手本质是 HTTP GET 升级请求,只需控制 URL(路径 + 查询参数)。
|
||||
///
|
||||
/// App 层可在此(通过调用 cipher_guard_sdk):
|
||||
/// - 加密 URL 路径(如 `/ws` → `/hex(encrypt(ws))`)
|
||||
/// - 加密 token 参数(明文 token 不出现在 URL 中)
|
||||
/// - 添加加密模式协商参数(如 `cipher=true&type=mode3`)
|
||||
///
|
||||
/// null 时使用默认行为:在 URL 后追加 `?token=xxx`。
|
||||
typedef OnBuildConnectUrl = String Function(String url, String? token);
|
||||
|
||||
/// WebSocket 发送前加密回调
|
||||
typedef OnEncryptMessage = Future<String> Function(String plainText);
|
||||
|
||||
/// WebSocket 收到后解密回调
|
||||
typedef OnDecryptMessage = Future<String> Function(String cipherText);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:networks_sdk/src/data/repositories/networks_messaging_repository_impl.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/socket_connection_state.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/socket_error.dart';
|
||||
@@ -5,7 +7,7 @@ import 'package:networks_sdk/src/domain/repositories/networks_messaging_reposito
|
||||
import 'package:networks_sdk/src/presentation/facade/networks_messaging_api.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/socket_config.dart';
|
||||
|
||||
/// Implementation of [NetworksMessagingApi] using [NetworksMessagingRepository]
|
||||
/// [NetworksMessagingApi] 的实现,透传给 [NetworksMessagingRepository]
|
||||
class NetworksMessagingApiImpl implements NetworksMessagingApi {
|
||||
NetworksMessagingRepository? _repository;
|
||||
|
||||
@@ -47,6 +49,12 @@ class NetworksMessagingApiImpl implements NetworksMessagingApi {
|
||||
return _repository!.connectionState;
|
||||
}
|
||||
|
||||
@override
|
||||
void updateToken(String token) {
|
||||
_checkInitialized();
|
||||
_repository!.updateToken(token);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> send(Map<String, dynamic> message) {
|
||||
_checkInitialized();
|
||||
@@ -59,6 +67,12 @@ class NetworksMessagingApiImpl implements NetworksMessagingApi {
|
||||
return _repository!.sendString(message);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> sendBytes(List<int> bytes) {
|
||||
_checkInitialized();
|
||||
return _repository!.sendBytes(bytes);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Map<String, dynamic>> get messageStream {
|
||||
_checkInitialized();
|
||||
@@ -71,6 +85,12 @@ class NetworksMessagingApiImpl implements NetworksMessagingApi {
|
||||
return _repository!.rawMessageStream;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Uint8List> get binaryMessageStream {
|
||||
_checkInitialized();
|
||||
return _repository!.binaryMessageStream;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<SocketConnectionState> get connectionStateStream {
|
||||
_checkInitialized();
|
||||
@@ -103,4 +123,3 @@ class NetworksMessagingApiImpl implements NetworksMessagingApi {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import '../../../networks_sdk.dart';
|
||||
import 'networks_sdk_core.dart';
|
||||
|
||||
/// SDK API Implementation
|
||||
/// [NetworksSdkApi] 的实现,透传给 Repository
|
||||
class NetworksSdkApiImpl implements NetworksSdkApi {
|
||||
final NetworksSdkCore _core;
|
||||
|
||||
@@ -14,6 +14,29 @@ class NetworksSdkApiImpl implements NetworksSdkApi {
|
||||
void initialize(ApiConfig apiConfig) => _core.repo.initialize(apiConfig);
|
||||
|
||||
@override
|
||||
Future<T?> executeRequest<T>(ApiRequestable<T> request) => _core.repo.executeRequest(request);
|
||||
|
||||
Future<T?> executeRequest<T>(
|
||||
ApiRequestable<T> request, {
|
||||
CancelToken? cancelToken,
|
||||
}) {
|
||||
return _core.repo.executeRequest(request, cancelToken: cancelToken);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> executeDownload({
|
||||
required String url,
|
||||
required String savePath,
|
||||
OnDownloadProgress? onProgress,
|
||||
CancelToken? cancelToken,
|
||||
bool resume = false,
|
||||
Map<String, String>? headers,
|
||||
}) {
|
||||
return _core.repo.executeDownload(
|
||||
url: url,
|
||||
savePath: savePath,
|
||||
onProgress: onProgress,
|
||||
cancelToken: cancelToken,
|
||||
resume: resume,
|
||||
headers: headers,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import 'package:networks_sdk/src/presentation/wiring/network_callbacks.dart';
|
||||
|
||||
/// WebSocket 配置
|
||||
/// 非单例,由 App 层构造并注入到 SocketClient
|
||||
///
|
||||
/// 与 [ApiConfig] 设计一致:SDK 不依赖 Flutter,
|
||||
/// 网络检测、生命周期等业务逻辑通过回调注入。
|
||||
class SocketConfig {
|
||||
// ── 心跳 ──
|
||||
|
||||
/// 应用层心跳间隔(定时发送 "ping" 字符串)
|
||||
final Duration heartbeatInterval;
|
||||
|
||||
@@ -13,10 +17,19 @@ class SocketConfig {
|
||||
/// Pong 超时(超过此时间未收到 pong 则判定连接断开)
|
||||
final Duration pongTimeout;
|
||||
|
||||
// ── 连接 ──
|
||||
|
||||
/// 连接超时
|
||||
final Duration connectTimeout;
|
||||
|
||||
/// 是否启用 WebSocket 压缩(permessage-deflate)
|
||||
final bool enableCompression;
|
||||
|
||||
// ── 重连 ──
|
||||
|
||||
/// 最大重连次数(0 = 不重连)
|
||||
///
|
||||
/// 当 [unlimitedReconnect] 为 true 时此字段无效。
|
||||
final int maxReconnectAttempts;
|
||||
|
||||
/// 最大重连延迟(指数退避上限)
|
||||
@@ -25,22 +38,65 @@ class SocketConfig {
|
||||
/// 是否自动重连
|
||||
final bool autoReconnect;
|
||||
|
||||
/// 无限重连模式
|
||||
///
|
||||
/// IM 场景建议开启:连接断开后始终尝试重连,不受
|
||||
/// [maxReconnectAttempts] 限制。退避延迟仍受
|
||||
/// [maxReconnectDelay] 约束。
|
||||
final bool unlimitedReconnect;
|
||||
|
||||
// ── 回调 ──
|
||||
|
||||
/// 日志输出回调(与 ApiConfig.onLog 同签名)
|
||||
final void Function(String message, {String? tag})? onLog;
|
||||
final OnLog? onLog;
|
||||
|
||||
/// 网络可用性查询(App 层注入,SDK 在重连前调用)
|
||||
/// 返回 true 表示网络可用,可以尝试重连
|
||||
final Future<bool> Function()? onCheckNetworkAvailable;
|
||||
final OnCheckNetworkAvailable? onCheckNetworkAvailable;
|
||||
|
||||
/// 重连前回调
|
||||
///
|
||||
/// 每次自动重连前调用(心跳超时、连接断开等触发的内部重连)。
|
||||
/// App 层用于:
|
||||
/// - 检查并刷新即将过期的 token(通过 [SocketClient.updateToken])
|
||||
/// - 其他重连前准备工作
|
||||
///
|
||||
/// 回调完成后才发起实际连接。如果回调抛出异常,本次重连跳过,
|
||||
/// 等下一轮退避定时器触发。
|
||||
final Future<void> Function()? onBeforeReconnect;
|
||||
|
||||
// ── 加密回调(预留给 cipher_guard_sdk)──
|
||||
|
||||
/// 连接 URL 构建回调
|
||||
///
|
||||
/// 建立连接前调用,接收原始 URL 和 token,返回最终连接 URL 字符串。
|
||||
/// null 时使用默认行为(URL 后追加 `?token=xxx`)。
|
||||
///
|
||||
/// App 层注入 cipher_guard_sdk 的加密逻辑:路径/token 加密、
|
||||
/// 添加 `cipher=true` 参数等。
|
||||
final OnBuildConnectUrl? onBuildConnectUrl;
|
||||
|
||||
/// 发送前加密回调,null 时不加密
|
||||
final OnEncryptMessage? onEncryptMessage;
|
||||
|
||||
/// 收到后解密回调,null 时不解密
|
||||
final OnDecryptMessage? onDecryptMessage;
|
||||
|
||||
SocketConfig({
|
||||
this.heartbeatInterval = const Duration(seconds: 10),
|
||||
this.pingInterval = const Duration(seconds: 5),
|
||||
this.pongTimeout = const Duration(seconds: 10),
|
||||
this.connectTimeout = const Duration(seconds: 15),
|
||||
this.enableCompression = false,
|
||||
this.maxReconnectAttempts = 5,
|
||||
this.maxReconnectDelay = const Duration(seconds: 30),
|
||||
this.autoReconnect = true,
|
||||
this.unlimitedReconnect = false,
|
||||
this.onLog,
|
||||
this.onCheckNetworkAvailable,
|
||||
this.onBeforeReconnect,
|
||||
this.onBuildConnectUrl,
|
||||
this.onEncryptMessage,
|
||||
this.onDecryptMessage,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
group = "com.example.notification_sdk"
|
||||
version = "1.0-SNAPSHOT"
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = "2.2.20"
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.11.1")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: "com.android.library"
|
||||
apply plugin: "kotlin-android"
|
||||
|
||||
android {
|
||||
namespace = "com.example.notification_sdk"
|
||||
|
||||
compileSdk = 36
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += "src/main/kotlin"
|
||||
test.java.srcDirs += "src/test/kotlin"
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
||||
testImplementation("org.mockito:mockito-core:5.0.0")
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.all {
|
||||
useJUnitPlatform()
|
||||
|
||||
testLogging {
|
||||
events "passed", "skipped", "failed", "standardOut", "standardError"
|
||||
outputs.upToDateWhen {false}
|
||||
showStandardStreams = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
71
packages/notification_sdk/android/build.gradle.kts
Normal file
71
packages/notification_sdk/android/build.gradle.kts
Normal file
@@ -0,0 +1,71 @@
|
||||
group = "com.example.notification_sdk"
|
||||
version = "1.0-SNAPSHOT"
|
||||
|
||||
buildscript {
|
||||
val kotlinVersion = "2.2.20"
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.11.1")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.notification_sdk"
|
||||
compileSdk = 36
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") { java.srcDirs("src/main/kotlin") }
|
||||
getByName("test") { java.srcDirs("src/test/kotlin") }
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
all {
|
||||
it.useJUnitPlatform()
|
||||
it.outputs.upToDateWhen { false }
|
||||
it.testLogging {
|
||||
events("passed", "skipped", "failed", "standardOut", "standardError")
|
||||
showStandardStreams = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Compose BOM 统一管理子库版本,按需在各 SDK 中引入具体组件
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
||||
testImplementation("org.mockito:mockito-core:5.0.0")
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import Flutter
|
||||
@preconcurrency import Flutter
|
||||
import UIKit
|
||||
|
||||
public class NotificationSdkPlugin: NSObject, FlutterPlugin {
|
||||
|
||||
@@ -19,7 +19,7 @@ A new Flutter plugin project.
|
||||
|
||||
# Flutter.framework does not contain a i386 slice.
|
||||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
|
||||
s.swift_version = '5.0'
|
||||
s.swift_version = '6.2'
|
||||
|
||||
# If your plugin requires a privacy manifest, for example if it uses any
|
||||
# required reason APIs, update the PrivacyInfo.xcprivacy file to describe your
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
group = "com.example.protocol_sdk"
|
||||
version = "1.0-SNAPSHOT"
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = "2.2.20"
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.11.1")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: "com.android.library"
|
||||
apply plugin: "kotlin-android"
|
||||
|
||||
android {
|
||||
namespace = "com.example.protocol_sdk"
|
||||
|
||||
compileSdk = 36
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += "src/main/kotlin"
|
||||
test.java.srcDirs += "src/test/kotlin"
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
||||
testImplementation("org.mockito:mockito-core:5.0.0")
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.all {
|
||||
useJUnitPlatform()
|
||||
|
||||
testLogging {
|
||||
events "passed", "skipped", "failed", "standardOut", "standardError"
|
||||
outputs.upToDateWhen {false}
|
||||
showStandardStreams = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
71
packages/protocol_sdk/android/build.gradle.kts
Normal file
71
packages/protocol_sdk/android/build.gradle.kts
Normal file
@@ -0,0 +1,71 @@
|
||||
group = "com.example.protocol_sdk"
|
||||
version = "1.0-SNAPSHOT"
|
||||
|
||||
buildscript {
|
||||
val kotlinVersion = "2.2.20"
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.11.1")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.protocol_sdk"
|
||||
compileSdk = 36
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") { java.srcDirs("src/main/kotlin") }
|
||||
getByName("test") { java.srcDirs("src/test/kotlin") }
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
all {
|
||||
it.useJUnitPlatform()
|
||||
it.outputs.upToDateWhen { false }
|
||||
it.testLogging {
|
||||
events("passed", "skipped", "failed", "standardOut", "standardError")
|
||||
showStandardStreams = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Compose BOM 统一管理子库版本,按需在各 SDK 中引入具体组件
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
||||
testImplementation("org.mockito:mockito-core:5.0.0")
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user