Merge pull request '优化配置,修复 demo bug' (#6) from cody/netwrok_SDK into dev
Reviewed-on: https://gitea.winwayinfo.com/CUS-IM/customer-im-client/pulls/6
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("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.11.1" apply false
|
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.android") version "2.2.20" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@@ -38,5 +38,10 @@ end
|
|||||||
post_install do |installer|
|
post_install do |installer|
|
||||||
installer.pods_project.targets.each do |target|
|
installer.pods_project.targets.each do |target|
|
||||||
flutter_additional_ios_build_settings(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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -164,7 +164,6 @@
|
|||||||
1C416905D0EA345032C4E612 /* Pods-RunnerTests.release.xcconfig */,
|
1C416905D0EA345032C4E612 /* Pods-RunnerTests.release.xcconfig */,
|
||||||
9538107A41BCB5B5D84FBAF3 /* Pods-RunnerTests.profile.xcconfig */,
|
9538107A41BCB5B5D84FBAF3 /* Pods-RunnerTests.profile.xcconfig */,
|
||||||
);
|
);
|
||||||
name = Pods;
|
|
||||||
path = Pods;
|
path = Pods;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -489,7 +488,7 @@
|
|||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 6.0;
|
||||||
TARGETED_DEVICE_FAMILY = 1;
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
};
|
};
|
||||||
@@ -508,7 +507,7 @@
|
|||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 6.2;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
@@ -524,7 +523,7 @@
|
|||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 6.2;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
@@ -540,7 +539,7 @@
|
|||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 6.2;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
};
|
};
|
||||||
name = Profile;
|
name = Profile;
|
||||||
@@ -678,7 +677,7 @@
|
|||||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 6.0;
|
||||||
TARGETED_DEVICE_FAMILY = 1;
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
};
|
};
|
||||||
@@ -705,7 +704,7 @@
|
|||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 6.0;
|
||||||
TARGETED_DEVICE_FAMILY = 1;
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import Flutter
|
@preconcurrency import Flutter
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
|
|
||||||
override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
@@ -16,9 +16,11 @@ import UIKit
|
|||||||
sceneConfig.delegateClass = SceneDelegate.self
|
sceneConfig.delegateClass = SceneDelegate.self
|
||||||
return sceneConfig
|
return sceneConfig
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - FlutterImplicitEngineDelegate
|
// FlutterImplicitEngineDelegate 来自 Flutter ObjC 框架,尚未标注 @MainActor,
|
||||||
|
// 用 @preconcurrency 抑制 Swift 6 ConformanceIsolation 错误。
|
||||||
|
extension AppDelegate: @preconcurrency FlutterImplicitEngineDelegate {
|
||||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,18 @@ class AuthNotifier extends ChangeNotifier {
|
|||||||
|
|
||||||
void login() {
|
void login() {
|
||||||
_isLoggedIn = true;
|
_isLoggedIn = true;
|
||||||
|
// TODO: 接入 cipher_guard_sdk 后,在此处完成 RSA 密钥注入:
|
||||||
|
// 1. 从安全存储(keychain / secure storage)读取公私钥对(只读一次)
|
||||||
|
// 2. cipherSdk.setActiveKeyPair(publicKey: pubPem, privateKey: privPem)
|
||||||
|
// 须在 notifyListeners() 之前完成,确保路由跳转后 onEncryptRequest 回调触发时密钥已就绪。
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void logout() {
|
void logout() {
|
||||||
_isLoggedIn = false;
|
_isLoggedIn = false;
|
||||||
|
// TODO: 接入 cipher_guard_sdk 后,退出登录时清除内存密钥:
|
||||||
|
// cipherSdk.clearActiveKeyPair()
|
||||||
|
// cipherSdk.clearDerivedKeyCache()
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,9 +44,7 @@ class AuthNotifier extends ChangeNotifier {
|
|||||||
/// 使用 [Provider] 持有 [AuthNotifier] 单例。
|
/// 使用 [Provider] 持有 [AuthNotifier] 单例。
|
||||||
/// go_router 通过 [GoRouter.refreshListenable] 直接监听 [AuthNotifier](ChangeNotifier),
|
/// go_router 通过 [GoRouter.refreshListenable] 直接监听 [AuthNotifier](ChangeNotifier),
|
||||||
/// Riverpod 侧不需要响应式更新(导航由 go_router 接管)。
|
/// Riverpod 侧不需要响应式更新(导航由 go_router 接管)。
|
||||||
final authNotifierProvider = Provider<AuthNotifier>(
|
final authNotifierProvider = Provider<AuthNotifier>((ref) => AuthNotifier());
|
||||||
(ref) => AuthNotifier(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── 主题 ──────────────────────────────────────────────────────────────────────
|
// ── 主题 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import 'package:networks_sdk/networks_sdk.dart';
|
|||||||
import '../../core/foundation/api_paths.dart';
|
import '../../core/foundation/api_paths.dart';
|
||||||
import '../../core/foundation/config.dart';
|
import '../../core/foundation/config.dart';
|
||||||
import '../../core/foundation/constants.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/network_monitor.dart';
|
||||||
import '../../core/services/socket_manager.dart';
|
import '../../core/services/socket_manager.dart';
|
||||||
|
|
||||||
@@ -47,6 +49,21 @@ final networkMonitorProvider = Provider<NetworkMonitor>((ref) {
|
|||||||
return monitor;
|
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 基础设施 ─────────────────────────────────────────────────────────────
|
// ── HTTP 基础设施 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// API 配置 Provider(全局单例)
|
/// API 配置 Provider(全局单例)
|
||||||
@@ -58,15 +75,18 @@ final networkMonitorProvider = Provider<NetworkMonitor>((ref) {
|
|||||||
/// 请求前先判断网络状态,无网络时直接抛 [ApiError.noNetworkConnection]。
|
/// 请求前先判断网络状态,无网络时直接抛 [ApiError.noNetworkConnection]。
|
||||||
final apiConfigProvider = Provider<ApiConfig>((ref) {
|
final apiConfigProvider = Provider<ApiConfig>((ref) {
|
||||||
final networkMonitor = ref.read(networkMonitorProvider);
|
final networkMonitor = ref.read(networkMonitorProvider);
|
||||||
|
final tokenStream = ref.read(_tokenUpdateStreamProvider);
|
||||||
|
|
||||||
return ApiConfig(
|
return ApiConfig(
|
||||||
baseURL: AppConfig.apiBaseUrl,
|
baseURL: AppConfig.apiBaseUrl,
|
||||||
platformHeaders: {
|
platformHeaders: {
|
||||||
'Platform': 'Android', // TODO: 运行时从平台 API 获取
|
'Platform': 'Android', // TODO: 运行时从 platform API 获取
|
||||||
'client-version': '1.0.0', // TODO: 运行时从 package_info 获取
|
'client-version': '1.0.0', // TODO: 运行时从 package_info 获取
|
||||||
|
'Channel': '', // TODO: 从 AppConfig 读取渠道标识
|
||||||
|
'lang': 'zh-CN', // TODO: 从 l10n_sdk 或系统 locale 动态获取
|
||||||
},
|
},
|
||||||
tokenExpiredCodes: {30002, 30003, 30124},
|
tokenExpiredCodes: ApiErrorCodes.tokenExpiredCodes,
|
||||||
forceLogoutCodes: {30125},
|
forceLogoutCodes: ApiErrorCodes.forceLogoutCodes,
|
||||||
onForceLogout: () {
|
onForceLogout: () {
|
||||||
// TODO: 清除登录态,跳转登录页
|
// TODO: 清除登录态,跳转登录页
|
||||||
},
|
},
|
||||||
@@ -74,7 +94,33 @@ final apiConfigProvider = Provider<ApiConfig>((ref) {
|
|||||||
// TODO: App 层刷新 token 逻辑
|
// TODO: App 层刷新 token 逻辑
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
onTokenUpdated: (newToken) {
|
||||||
|
// 通过事件流同步到 WebSocket,避免直接引用 socketManagerProvider 造成循环依赖
|
||||||
|
tokenStream.add(newToken);
|
||||||
|
},
|
||||||
onCheckNetworkAvailable: () async => networkMonitor.isConnected,
|
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,
|
maxRetries: AppConstants.maxRetries,
|
||||||
retryBaseDelay: AppConstants.retryBaseDelay,
|
retryBaseDelay: AppConstants.retryBaseDelay,
|
||||||
onLog: (message, {tag}) {
|
onLog: (message, {tag}) {
|
||||||
@@ -94,16 +140,47 @@ final networkSdkApiProvider = Provider<NetworksSdkApi>((ref) {
|
|||||||
|
|
||||||
// ── WebSocket 基础设施 ────────────────────────────────────────────────────────
|
// ── WebSocket 基础设施 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// SocketConfig Provider(全局单例)
|
/// SocketConfig Provider(内部使用,不对外暴露)
|
||||||
///
|
///
|
||||||
/// 与 apiConfigProvider 对称,通过回调注入 App 层能力,
|
/// 与 apiConfigProvider 对称,通过回调注入 App 层能力,
|
||||||
/// SDK 内部不调用其他 SDK。
|
/// SDK 内部不调用其他 SDK。
|
||||||
final socketConfigProvider = Provider<SocketConfig>((ref) {
|
final _socketConfigProvider = Provider<SocketConfig>((ref) {
|
||||||
final networkMonitor = ref.read(networkMonitorProvider);
|
final networkMonitor = ref.read(networkMonitorProvider);
|
||||||
|
|
||||||
return SocketConfig(
|
return SocketConfig(
|
||||||
maxReconnectAttempts: AppConstants.maxRetries,
|
maxReconnectAttempts: AppConstants.maxRetries,
|
||||||
maxReconnectDelay: AppConstants.maxReconnectDelay,
|
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}) {
|
onLog: (message, {tag}) {
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print('[${tag ?? 'Socket'}] $message');
|
print('[${tag ?? 'Socket'}] $message');
|
||||||
@@ -114,12 +191,11 @@ final socketConfigProvider = Provider<SocketConfig>((ref) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/// SocketClient Provider(全局单例)
|
/// SocketClient Provider(内部使用,不对外暴露)
|
||||||
///
|
///
|
||||||
/// 与 apiClientProvider 对称。
|
/// 与 networkSdkApiProvider 对称。
|
||||||
final socketClientProvider = Provider<NetworksMessagingApi>((ref)
|
final _socketClientProvider = Provider<NetworksMessagingApi>((ref) {
|
||||||
{
|
final config = ref.read(_socketConfigProvider);
|
||||||
final config = ref.read(socketConfigProvider);
|
|
||||||
return NetworksMessagingApi()..initialize(config);
|
return NetworksMessagingApi()..initialize(config);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,17 +215,44 @@ final socketClientProvider = Provider<NetworksMessagingApi>((ref)
|
|||||||
/// 网络状态变化由 [networkMonitorProvider](公共服务)驱动,
|
/// 网络状态变化由 [networkMonitorProvider](公共服务)驱动,
|
||||||
/// 自动触发断连/重连。
|
/// 自动触发断连/重连。
|
||||||
///
|
///
|
||||||
|
/// Token 更新由 [_tokenUpdateStreamProvider] 事件流驱动,
|
||||||
|
/// HTTP 层刷新 token 后自动同步到 WebSocket。
|
||||||
|
///
|
||||||
/// onMessageTransform 参考 HTTP 层 onTokenRefresh 的回调模式:
|
/// onMessageTransform 参考 HTTP 层 onTokenRefresh 的回调模式:
|
||||||
/// 后续接入加解密 SDK 时,在此注入解密回调,
|
/// 后续接入加解密 SDK 时,在此注入解密回调,
|
||||||
/// SDK 内部不调用其他 SDK。
|
/// SDK 内部不调用其他 SDK。
|
||||||
final socketManagerProvider = Provider<SocketManager>((ref) {
|
final socketManagerProvider = Provider<SocketManager>((ref) {
|
||||||
final client = ref.read(socketClientProvider);
|
final client = ref.read(_socketClientProvider);
|
||||||
final networkMonitor = ref.read(networkMonitorProvider);
|
final networkMonitor = ref.read(networkMonitorProvider);
|
||||||
|
final apiConfig = ref.read(apiConfigProvider);
|
||||||
|
final tokenStream = ref.read(_tokenUpdateStreamProvider);
|
||||||
|
|
||||||
final manager = SocketManager(
|
final manager = SocketManager(
|
||||||
client: client,
|
client: client,
|
||||||
wsUrl: _buildWsUrl(AppConfig.apiBaseUrl),
|
wsUrl: _buildWsUrl(AppConfig.apiBaseUrl),
|
||||||
|
disconnectInBackground: false, // 所有平台后台保活,心跳不停、连接不断
|
||||||
onMessageTransform: null, // TODO: 接入加解密 SDK 后注入解密回调
|
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,
|
onCheckNetworkAvailable: () async => networkMonitor.isConnected,
|
||||||
onLog: (message, {tag}) {
|
onLog: (message, {tag}) {
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
@@ -157,13 +260,19 @@ final socketManagerProvider = Provider<SocketManager>((ref) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 监听 token 更新事件 → 同步到 WebSocket
|
||||||
|
final tokenSub = tokenStream.stream.listen((newToken) {
|
||||||
|
manager.updateToken(newToken);
|
||||||
|
});
|
||||||
|
|
||||||
// 监听网络状态变化 → 驱动 SocketManager 断连/重连
|
// 监听网络状态变化 → 驱动 SocketManager 断连/重连
|
||||||
final subscription = networkMonitor.onStatusChanged.listen((isAvailable) {
|
final networkSub = networkMonitor.onStatusChanged.listen((isAvailable) {
|
||||||
manager.handleNetworkStatusChanged(isAvailable: isAvailable);
|
manager.handleNetworkStatusChanged(isAvailable: isAvailable);
|
||||||
});
|
});
|
||||||
|
|
||||||
ref.onDispose(() {
|
ref.onDispose(() {
|
||||||
subscription.cancel();
|
tokenSub.cancel();
|
||||||
|
networkSub.cancel();
|
||||||
unawaited(manager.dispose());
|
unawaited(manager.dispose());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -215,23 +324,57 @@ String _buildWsUrl(String httpBaseUrl) {
|
|||||||
// Provider 链路:
|
// Provider 链路:
|
||||||
//
|
//
|
||||||
// networkMonitorProvider(公共服务,HTTP + WS 共用)
|
// networkMonitorProvider(公共服务,HTTP + WS 共用)
|
||||||
// ├── apiConfigProvider → apiClientProvider ← HTTP 层
|
// ├── apiConfigProvider → networkSdkApiProvider ← HTTP 层
|
||||||
// └── socketConfigProvider → socketClientProvider ← WS 层
|
// └── _socketConfigProvider → _socketClientProvider ← WS 层(内部)
|
||||||
// → socketManagerProvider
|
// → socketManagerProvider
|
||||||
//
|
//
|
||||||
|
// _tokenUpdateStreamProvider(打破循环引用的中间层)
|
||||||
|
// ← apiConfigProvider.onTokenUpdated 推送
|
||||||
|
// → socketManagerProvider 监听 → socketManager.updateToken()
|
||||||
|
//
|
||||||
// 网络事件驱动链路:
|
// 网络事件驱动链路:
|
||||||
//
|
//
|
||||||
// connectivity_plus(平台网络事件)
|
// connectivity_plus(平台网络事件)
|
||||||
// → NetworkMonitor.onStatusChanged(true / false)
|
// → NetworkMonitor.onStatusChanged(true / false)
|
||||||
// → SocketManager.handleNetworkStatusChanged()
|
// → SocketManager.handleNetworkStatusChanged()
|
||||||
// → 断网: disconnect()
|
// → 断网: disconnect()
|
||||||
// → 恢复: connect(token: lastToken)
|
// → 恢复: onBeforeReconnect → connect(token: lastToken)
|
||||||
//
|
//
|
||||||
// 前后台事件驱动链路:
|
// 前后台事件驱动链路:
|
||||||
//
|
//
|
||||||
// WidgetsBindingObserver(App 层 app.dart)
|
// WidgetsBindingObserver(App 层 app.dart)
|
||||||
// → SocketManager.onEnterBackground() → disconnect
|
// → SocketManager.onEnterBackground()
|
||||||
// → SocketManager.onEnterForeground() → reconnect
|
// 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 能力:
|
// Repository 直接注入 ApiClient,通过回调注入其他 SDK 能力:
|
||||||
//
|
//
|
||||||
@@ -313,7 +456,7 @@ String _buildWsUrl(String httpBaseUrl) {
|
|||||||
// final authRepositoryProvider = Provider((ref) {
|
// final authRepositoryProvider = Provider((ref) {
|
||||||
// final apiConfig = ref.read(apiConfigProvider);
|
// final apiConfig = ref.read(apiConfigProvider);
|
||||||
// return AuthRepositoryImpl(
|
// return AuthRepositoryImpl(
|
||||||
// client: ref.read(apiClientProvider), // 直接注入
|
// client: ref.read(networkSdkApiProvider), // 注入 Facade 接口
|
||||||
// onTokenUpdate: (token) {
|
// onTokenUpdate: (token) {
|
||||||
// apiConfig.updateToken(token); // 内存(network_sdk)
|
// apiConfig.updateToken(token); // 内存(network_sdk)
|
||||||
// // secureStorage.saveToken(token); // 持久化(crypto_sdk)
|
// // secureStorage.saveToken(token); // 持久化(crypto_sdk)
|
||||||
@@ -400,5 +543,5 @@ String _buildWsUrl(String httpBaseUrl) {
|
|||||||
// Upload B: 二进制上传到 S3 presigned URL
|
// Upload B: 二进制上传到 S3 presigned URL
|
||||||
// @override String get path => presignedURL; // 完整 URL,不拼 baseURL
|
// @override String get path => presignedURL; // 完整 URL,不拼 baseURL
|
||||||
// @override Object? get uploadData => bytes; // Uint8List
|
// @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 'dart:async';
|
||||||
|
|
||||||
|
|
||||||
import 'package:networks_sdk/networks_sdk.dart';
|
import 'package:networks_sdk/networks_sdk.dart';
|
||||||
|
|
||||||
import 'network_backoff_debouncer.dart';
|
import 'network_backoff_debouncer.dart';
|
||||||
@@ -10,15 +9,14 @@ import 'network_backoff_debouncer.dart';
|
|||||||
/// 参考 HTTP 层 onTokenRefresh 的回调注入模式。
|
/// 参考 HTTP 层 onTokenRefresh 的回调注入模式。
|
||||||
/// App 层在 Provider 装配时注入解密/解析逻辑,
|
/// App 层在 Provider 装配时注入解密/解析逻辑,
|
||||||
/// 不在 SDK 内部调用加解密 SDK。
|
/// 不在 SDK 内部调用加解密 SDK。
|
||||||
typedef MessageTransformer = Map<String, dynamic> Function(
|
typedef MessageTransformer =
|
||||||
Map<String, dynamic> raw,
|
Map<String, dynamic> Function(Map<String, dynamic> raw);
|
||||||
);
|
|
||||||
|
|
||||||
/// WebSocket 连接管理
|
/// WebSocket 连接管理
|
||||||
///
|
///
|
||||||
/// 在 SocketClient(SDK 底层能力)之上封装:
|
/// 在 SocketClient(SDK 底层能力)之上封装:
|
||||||
/// - 连接/断连生命周期(登录连接、登出断连)
|
/// - 连接/断连生命周期(登录连接、登出断连)
|
||||||
/// - 前后台生命周期(后台断连省电、前台自动重连)
|
/// - 前后台生命周期(两种模式:后台断连 / 后台保活)
|
||||||
/// - 网络状态响应(断网断连、恢复网络立即重连)
|
/// - 网络状态响应(断网断连、恢复网络立即重连)
|
||||||
/// - 操作前置检查(网络可用性 + 后台状态)
|
/// - 操作前置检查(网络可用性 + 后台状态)
|
||||||
/// - 消息预处理管道(通过 [onMessageTransform] 回调注入解密等)
|
/// - 消息预处理管道(通过 [onMessageTransform] 回调注入解密等)
|
||||||
@@ -39,19 +37,26 @@ typedef MessageTransformer = Map<String, dynamic> Function(
|
|||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// 登录成功 → connect(token) → 前置检查 → 建立连接
|
/// 登录成功 → connect(token) → 前置检查 → 建立连接
|
||||||
/// App 进后台 → onEnterBackground() → 断开连接(省电)
|
///
|
||||||
/// App 回前台 → onEnterForeground() → 检查网络 → 自动重连
|
/// ── disconnectInBackground = true(后台断连模式)──
|
||||||
|
/// App 进后台 → onEnterBackground() → 暂停心跳 + 断开连接(省电)
|
||||||
|
/// App 回前台 → onEnterForeground() → 恢复心跳 → onBeforeReconnect → 重连
|
||||||
|
///
|
||||||
|
/// ── disconnectInBackground = false(后台保活模式,本项目默认)──
|
||||||
|
/// App 进后台 → onEnterBackground() → 不操作,心跳不停、连接不断
|
||||||
|
/// App 回前台 → onEnterForeground() → 检查连接健康,异常则重连
|
||||||
|
///
|
||||||
/// 网络丢失 → handleNetworkLost() → 断开连接
|
/// 网络丢失 → handleNetworkLost() → 断开连接
|
||||||
/// 网络恢复 → handleNetworkRestored() → 退避重连(防抖动)
|
/// 网络恢复 → handleNetworkRestored() → 退避 → onBeforeReconnect → 重连
|
||||||
/// 登出 → disconnect() → 断开连接,清除 token
|
/// 登出 → disconnect() → 断开连接,清除 token
|
||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
/// ## 前置检查策略
|
/// ## 前置检查策略
|
||||||
///
|
///
|
||||||
/// 所有会发起网络操作的方法都先检查前置条件:
|
/// 所有会发起网络操作的方法都先检查前置条件:
|
||||||
/// - connect → 检查网络可用性 + 是否在后台
|
/// - connect → 检查网络可用性 + 是否在后台(仅 disconnectInBackground=true 时拦截)
|
||||||
/// - send / sendString → 检查连接状态 + 是否在后台
|
/// - send / sendString → 检查连接状态 + 是否在后台(仅 disconnectInBackground=true 时拦截)
|
||||||
/// - onEnterForeground 重连 → 检查网络可用性
|
/// - onEnterForeground / 网络恢复重连 → 检查网络可用性 + onBeforeReconnect
|
||||||
class SocketManager {
|
class SocketManager {
|
||||||
final NetworksMessagingApi _client;
|
final NetworksMessagingApi _client;
|
||||||
final String _wsUrl;
|
final String _wsUrl;
|
||||||
@@ -70,6 +75,22 @@ class SocketManager {
|
|||||||
/// 连接和重连前调用,无网络时跳过操作并标记恢复时重试。
|
/// 连接和重连前调用,无网络时跳过操作并标记恢复时重试。
|
||||||
final Future<bool> Function()? onCheckNetworkAvailable;
|
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;
|
final void Function(String message, {String? tag})? onLog;
|
||||||
|
|
||||||
@@ -104,10 +125,12 @@ class SocketManager {
|
|||||||
required NetworksMessagingApi client,
|
required NetworksMessagingApi client,
|
||||||
required String wsUrl,
|
required String wsUrl,
|
||||||
this.onMessageTransform,
|
this.onMessageTransform,
|
||||||
|
this.onBeforeReconnect,
|
||||||
|
this.disconnectInBackground = true,
|
||||||
this.onCheckNetworkAvailable,
|
this.onCheckNetworkAvailable,
|
||||||
this.onLog,
|
this.onLog,
|
||||||
}) : _client = client,
|
}) : _client = client,
|
||||||
_wsUrl = wsUrl;
|
_wsUrl = wsUrl;
|
||||||
|
|
||||||
// ── 连接 ──────────────────────────────────────────────────────────────────
|
// ── 连接 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -124,8 +147,8 @@ class SocketManager {
|
|||||||
_reconnectOnForeground = false;
|
_reconnectOnForeground = false;
|
||||||
_reconnectOnNetworkRestore = false;
|
_reconnectOnNetworkRestore = false;
|
||||||
|
|
||||||
// 前置检查:在后台不连接(省电)
|
// 前置检查:后台断连模式下在后台不连接(省电)
|
||||||
if (_isInBackground) {
|
if (_isInBackground && disconnectInBackground) {
|
||||||
_reconnectOnForeground = true;
|
_reconnectOnForeground = true;
|
||||||
_log('In background, defer connect to foreground');
|
_log('In background, defer connect to foreground');
|
||||||
return false;
|
return false;
|
||||||
@@ -165,26 +188,47 @@ class SocketManager {
|
|||||||
/// 当前是否在后台
|
/// 当前是否在后台
|
||||||
bool get isInBackground => _isInBackground;
|
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] 时调用。
|
/// 由 App 层 WidgetsBindingObserver 在 [AppLifecycleState.paused] 时调用。
|
||||||
/// 后台保持连接会消耗电量和流量,断开后由 push 通知兜底。
|
///
|
||||||
|
/// [disconnectInBackground] 为 false 时(后台保活,本项目默认):
|
||||||
|
/// 不断连、不暂停心跳,WebSocket 完全保活。
|
||||||
|
///
|
||||||
|
/// [disconnectInBackground] 为 true 时(后台断连模式):
|
||||||
|
/// 断开连接 + 暂停心跳,由 push 通知兜底,前台恢复时自动重连。
|
||||||
void onEnterBackground() {
|
void onEnterBackground() {
|
||||||
_isInBackground = true;
|
_isInBackground = true;
|
||||||
// 取消待执行的前台重连(防止快速 前台→后台 切换导致后台建连)
|
// 取消待执行的前台重连(防止快速 前台→后台 切换导致后台建连)
|
||||||
_foregroundReconnectTimer?.cancel();
|
_foregroundReconnectTimer?.cancel();
|
||||||
_foregroundReconnectTimer = null;
|
_foregroundReconnectTimer = null;
|
||||||
// 同步 SocketClient 内部状态(与 onEnterForeground 对称)
|
|
||||||
|
if (!disconnectInBackground) {
|
||||||
|
// 后台保活模式:不断连、不暂停心跳,不通知 SocketClient
|
||||||
|
_log('Entering background, keeping connection alive');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后台断连模式:通知 SocketClient 进后台(暂停心跳)
|
||||||
_client.onEnterBackground();
|
_client.onEnterBackground();
|
||||||
|
|
||||||
if (_lastToken == null) return; // 未登录,无需处理
|
if (_lastToken == null) return; // 未登录,无需处理
|
||||||
|
|
||||||
// 与 _handleNetworkLost 保持一致:
|
|
||||||
// 不仅 connected,connecting / reconnecting 也要断开,
|
// 不仅 connected,connecting / reconnecting 也要断开,
|
||||||
// 防止 SocketClient 在后台继续尝试连接浪费电量和流量。
|
// 防止 SocketClient 在后台继续尝试连接浪费电量和流量。
|
||||||
if (_client.isConnected ||
|
if (_client.isConnected ||
|
||||||
@@ -196,41 +240,76 @@ class SocketManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// App 回前台 → 自动重连(如果之前后台断连)
|
/// App 回前台
|
||||||
///
|
///
|
||||||
/// 由 App 层 WidgetsBindingObserver 在 [AppLifecycleState.resumed] 时调用。
|
/// 由 App 层 WidgetsBindingObserver 在 [AppLifecycleState.resumed] 时调用。
|
||||||
/// 重连前检查网络可用性,无网络时延迟到网络恢复事件再连。
|
///
|
||||||
|
/// 后台保活模式(disconnectInBackground=false):
|
||||||
|
/// 检查连接健康,如果后台期间连接意外断开则自动重连。
|
||||||
|
///
|
||||||
|
/// 后台断连模式(disconnectInBackground=true):
|
||||||
|
/// 通知 SocketClient 恢复心跳,然后重新建立连接。
|
||||||
void onEnterForeground() {
|
void onEnterForeground() {
|
||||||
_isInBackground = false;
|
_isInBackground = false;
|
||||||
_client.onEnterForeground();
|
|
||||||
|
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) {
|
if (_reconnectOnForeground && _lastToken != null) {
|
||||||
|
// 后台断连模式:之前后台断连过,需要重连
|
||||||
_reconnectOnForeground = false;
|
_reconnectOnForeground = false;
|
||||||
_log('Returning to foreground, reconnecting...');
|
_log('Returning to foreground, reconnecting...');
|
||||||
// 延迟 500ms 等待网络稳定,通过 Timer 跟踪以便进后台时取消
|
_scheduleReconnect();
|
||||||
_foregroundReconnectTimer?.cancel();
|
}
|
||||||
_foregroundReconnectTimer = Timer(
|
}
|
||||||
const Duration(milliseconds: 500),
|
|
||||||
() async {
|
/// 延迟 500ms 后执行重连
|
||||||
_foregroundReconnectTimer = null;
|
///
|
||||||
// 双重保险:回调执行时再次检查后台状态
|
/// 等待网络稳定,通过 Timer 跟踪以便进后台时取消。
|
||||||
if (_isInBackground) {
|
void _scheduleReconnect() {
|
||||||
_reconnectOnForeground = true;
|
_foregroundReconnectTimer?.cancel();
|
||||||
_log('Went back to background during delay, skip reconnect');
|
_foregroundReconnectTimer = Timer(
|
||||||
|
const Duration(milliseconds: 500),
|
||||||
|
() async {
|
||||||
|
_foregroundReconnectTimer = null;
|
||||||
|
// 双重保险:回调执行时再次检查后台状态
|
||||||
|
if (_isInBackground) {
|
||||||
|
_reconnectOnForeground = true;
|
||||||
|
_log('Went back to background during delay, skip reconnect');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_client.isConnected && _lastToken != null) {
|
||||||
|
// 前置检查:网络可用性
|
||||||
|
if (!await _isNetworkAvailable()) {
|
||||||
|
_reconnectOnNetworkRestore = true;
|
||||||
|
_log('Network unavailable, defer reconnect to network restore');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!_client.isConnected && _lastToken != null) {
|
// 重连前钩子:刷新即将过期的 token 等
|
||||||
// 前置检查:网络可用性
|
await onBeforeReconnect?.call();
|
||||||
if (!await _isNetworkAvailable()) {
|
// token 可能被 onBeforeReconnect 更新(通过 updateToken 链路同步)
|
||||||
_reconnectOnNetworkRestore = true;
|
if (_lastToken != null && !_client.isConnected) {
|
||||||
_log('Network unavailable, defer reconnect to network restore');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_client.connect(_wsUrl, token: _lastToken!);
|
_client.connect(_wsUrl, token: _lastToken!);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
},
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 网络状态变化 ──────────────────────────────────────────────────────────
|
// ── 网络状态变化 ──────────────────────────────────────────────────────────
|
||||||
@@ -275,18 +354,22 @@ class SocketManager {
|
|||||||
if (_reconnectOnNetworkRestore && _lastToken != null) {
|
if (_reconnectOnNetworkRestore && _lastToken != null) {
|
||||||
_reconnectOnNetworkRestore = false;
|
_reconnectOnNetworkRestore = false;
|
||||||
|
|
||||||
// 在后台不重连,等前台恢复时再连
|
// 后台断连模式:在后台不重连,等前台恢复时再连
|
||||||
if (_isInBackground) {
|
if (_isInBackground && disconnectInBackground) {
|
||||||
_reconnectOnForeground = true;
|
_reconnectOnForeground = true;
|
||||||
_log('Network restored but in background, defer to foreground');
|
_log('Network restored but in background, defer to foreground');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_log('Network restored, scheduling reconnect with backoff');
|
_log('Network restored, scheduling reconnect with backoff');
|
||||||
_networkDebouncer.call(() {
|
_networkDebouncer.call(() async {
|
||||||
if (!_client.isConnected && _lastToken != null && !_isInBackground) {
|
if (!_client.isConnected && _lastToken != null && !_isInBackground) {
|
||||||
_log('Backoff timer fired, reconnecting');
|
// 重连前钩子:刷新即将过期的 token 等
|
||||||
_client.connect(_wsUrl, token: _lastToken!);
|
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<String> get rawMessageStream => _client.rawMessageStream;
|
||||||
|
|
||||||
|
/// 二进制消息流
|
||||||
|
Stream<dynamic> get binaryMessageStream => _client.binaryMessageStream;
|
||||||
|
|
||||||
/// 连接状态变化流
|
/// 连接状态变化流
|
||||||
Stream<SocketConnectionState> get connectionStateStream =>
|
Stream<SocketConnectionState> get connectionStateStream =>
|
||||||
_client.connectionStateStream;
|
_client.connectionStateStream;
|
||||||
@@ -333,6 +419,14 @@ class SocketManager {
|
|||||||
return _client.sendString(message);
|
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 通常就能拦住,
|
/// 后台已断连所以 isConnected 通常就能拦住,
|
||||||
/// 但显式检查 _isInBackground 防止边界情况遗漏。
|
/// 但显式检查 _isInBackground 防止边界情况遗漏。
|
||||||
bool _canSend() {
|
bool _canSend() {
|
||||||
@@ -355,8 +452,8 @@ class SocketManager {
|
|||||||
_log('Not connected, cannot send');
|
_log('Not connected, cannot send');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (_isInBackground) {
|
if (_isInBackground && disconnectInBackground) {
|
||||||
_log('In background, skip send');
|
_log('In background (disconnect mode), skip send');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
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';
|
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 {
|
class AppDatabase extends _$AppDatabase {
|
||||||
|
|
||||||
static Map<Type, TableInfo> getTableRegistry(GeneratedDatabase database) {
|
static Map<Type, TableInfo> getTableRegistry(GeneratedDatabase database) {
|
||||||
if (database is! AppDatabase) {
|
if (database is! AppDatabase) {
|
||||||
return {};
|
return {};
|
||||||
@@ -67,7 +88,9 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
// Create any new tables that don't exist yet
|
// Create any new tables that don't exist yet
|
||||||
for (final table in allTables) {
|
for (final table in allTables) {
|
||||||
final existingTables = await m.database
|
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();
|
.get();
|
||||||
|
|
||||||
if (existingTables.isEmpty) {
|
if (existingTables.isEmpty) {
|
||||||
@@ -92,6 +115,4 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ class CallLogs extends Table {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tableName => 'call_log';
|
String get tableName => 'call_log';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,4 +30,4 @@ class ChatBots extends Table {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tableName => 'chat_bot';
|
String get tableName => 'chat_bot';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,4 @@ class ChatCategories extends Table {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tableName => 'chat_category';
|
String get tableName => 'chat_category';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ class Chats extends Table {
|
|||||||
IntColumn get outgoingIdx => integer().withDefault(const Constant(0))();
|
IntColumn get outgoingIdx => integer().withDefault(const Constant(0))();
|
||||||
IntColumn get incomingSoundId => integer().withDefault(const Constant(0))();
|
IntColumn get incomingSoundId => integer().withDefault(const Constant(0))();
|
||||||
IntColumn get outgoingSoundId => 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 chatKey => text().withDefault(const Constant(''))();
|
||||||
TextColumn get activeChatKey => text().withDefault(const Constant(''))();
|
TextColumn get activeChatKey => text().withDefault(const Constant(''))();
|
||||||
IntColumn get coverIdx => integer().withDefault(const Constant(0))();
|
IntColumn get coverIdx => integer().withDefault(const Constant(0))();
|
||||||
@@ -55,4 +56,4 @@ class Chats extends Table {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tableName => 'chat';
|
String get tableName => 'chat';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,4 +33,4 @@ class DiscoverMiniApps extends Table {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tableName => 'discover_mini_app';
|
String get tableName => 'discover_mini_app';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,4 +33,4 @@ class ExploreMiniApps extends Table {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tableName => 'explore_mini_app';
|
String get tableName => 'explore_mini_app';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,4 +33,4 @@ class FavoriteMiniApps extends Table {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tableName => 'favorite_mini_app';
|
String get tableName => 'favorite_mini_app';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,4 +13,4 @@ class FavouriteDetails extends Table {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tableName => 'favourite_detail';
|
String get tableName => 'favourite_detail';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,4 +23,4 @@ class Favourites extends Table {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tableName => 'favourite';
|
String get tableName => 'favourite';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,4 +36,4 @@ class Groups extends Table {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tableName => 'chat_group';
|
String get tableName => 'chat_group';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,4 +24,4 @@ class Messages extends Table {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tableName => 'message';
|
String get tableName => 'message';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,4 +14,4 @@ class PendingFriendRequestHistories extends Table {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tableName => 'pending_friend_request_histories';
|
String get tableName => 'pending_friend_request_histories';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,4 +33,4 @@ class RecentMiniApps extends Table {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tableName => 'recent_mini_app';
|
String get tableName => 'recent_mini_app';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,4 @@ class Retries extends Table {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tableName => 'retry';
|
String get tableName => 'retry';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,4 @@ class Sounds extends Table {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tableName => 'sound';
|
String get tableName => 'sound';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,4 @@ class Tags extends Table {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tableName => 'tags';
|
String get tableName => 'tags';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ class UserRequestHistories extends Table {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tableName => 'user_request_history';
|
String get tableName => 'user_request_history';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,9 +28,12 @@ class Users extends Table {
|
|||||||
IntColumn get addIndex => integer().nullable()();
|
IntColumn get addIndex => integer().nullable()();
|
||||||
IntColumn get incomingSoundId => integer().withDefault(const Constant(0))();
|
IntColumn get incomingSoundId => integer().withDefault(const Constant(0))();
|
||||||
IntColumn get outgoingSoundId => integer().withDefault(const Constant(0))();
|
IntColumn get outgoingSoundId => integer().withDefault(const Constant(0))();
|
||||||
IntColumn get notificationSoundId => integer().withDefault(const Constant(0))();
|
IntColumn get notificationSoundId =>
|
||||||
IntColumn get sendMessageSoundId => integer().withDefault(const Constant(0))();
|
integer().withDefault(const Constant(0))();
|
||||||
IntColumn get groupNotificationSoundId => 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 groupTags => text().withDefault(const Constant('[]'))();
|
||||||
TextColumn get friendTags => text().withDefault(const Constant('[]'))();
|
TextColumn get friendTags => text().withDefault(const Constant('[]'))();
|
||||||
TextColumn get publicKey => text().nullable()();
|
TextColumn get publicKey => text().nullable()();
|
||||||
@@ -39,4 +42,4 @@ class Users extends Table {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tableName => 'user';
|
String get tableName => 'user';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ class Workspaces extends Table {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tableName => 'workspace';
|
String get tableName => 'workspace';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,4 +119,4 @@ class CallLogDto {
|
|||||||
deletedAt: Value(deletedAt),
|
deletedAt: Value(deletedAt),
|
||||||
isRead: Value(isRead),
|
isRead: Value(isRead),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,4 +176,4 @@ class ChatBotDto {
|
|||||||
isAllowForward: Value(isAllowForward),
|
isAllowForward: Value(isAllowForward),
|
||||||
tips: Value(tips),
|
tips: Value(tips),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,4 +87,4 @@ class ChatCategoryDto {
|
|||||||
updatedAt: Value(updatedAt),
|
updatedAt: Value(updatedAt),
|
||||||
deletedAt: Value(deletedAt),
|
deletedAt: Value(deletedAt),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,4 +197,4 @@ class Chat {
|
|||||||
localPermission: localPermission ?? this.localPermission,
|
localPermission: localPermission ?? this.localPermission,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,4 +109,4 @@ class DiscoverMiniApp {
|
|||||||
screen: screen ?? this.screen,
|
screen: screen ?? this.screen,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,34 +143,33 @@ class ExploreMiniAppDto {
|
|||||||
screen: screen,
|
screen: screen,
|
||||||
);
|
);
|
||||||
|
|
||||||
factory ExploreMiniAppDto.fromEntity(ExploreMiniApp app) =>
|
factory ExploreMiniAppDto.fromEntity(ExploreMiniApp app) => ExploreMiniAppDto(
|
||||||
ExploreMiniAppDto(
|
id: app.id,
|
||||||
id: app.id,
|
name: app.name,
|
||||||
name: app.name,
|
openuid: app.openuid,
|
||||||
openuid: app.openuid,
|
devId: app.devId,
|
||||||
devId: app.devId,
|
icon: app.icon,
|
||||||
icon: app.icon,
|
iconGaussian: app.iconGaussian,
|
||||||
iconGaussian: app.iconGaussian,
|
downloadUrl: app.downloadUrl,
|
||||||
downloadUrl: app.downloadUrl,
|
description: app.description,
|
||||||
description: app.description,
|
version: app.version,
|
||||||
version: app.version,
|
typ: app.typ,
|
||||||
typ: app.typ,
|
flag: app.flag,
|
||||||
flag: app.flag,
|
reviewStatus: app.reviewStatus,
|
||||||
reviewStatus: app.reviewStatus,
|
favoriteAt: app.favoriteAt,
|
||||||
favoriteAt: app.favoriteAt,
|
isActive: app.isActive,
|
||||||
isActive: app.isActive,
|
createdAt: app.createdAt,
|
||||||
createdAt: app.createdAt,
|
updatedAt: app.updatedAt,
|
||||||
updatedAt: app.updatedAt,
|
deletedAt: app.deletedAt,
|
||||||
deletedAt: app.deletedAt,
|
score: app.score,
|
||||||
score: app.score,
|
channels: app.channels,
|
||||||
channels: app.channels,
|
devName: app.devName,
|
||||||
devName: app.devName,
|
pictureGaussian: app.pictureGaussian,
|
||||||
pictureGaussian: app.pictureGaussian,
|
picture: app.picture,
|
||||||
picture: app.picture,
|
commentNum: app.commentNum,
|
||||||
commentNum: app.commentNum,
|
lastLoginAt: app.lastLoginAt,
|
||||||
lastLoginAt: app.lastLoginAt,
|
screen: app.screen,
|
||||||
screen: app.screen,
|
);
|
||||||
);
|
|
||||||
|
|
||||||
ExploreMiniAppsCompanion toCompanion() => ExploreMiniAppsCompanion(
|
ExploreMiniAppsCompanion toCompanion() => ExploreMiniAppsCompanion(
|
||||||
id: Value(id),
|
id: Value(id),
|
||||||
@@ -199,4 +198,4 @@ class ExploreMiniAppDto {
|
|||||||
lastLoginAt: Value(lastLoginAt),
|
lastLoginAt: Value(lastLoginAt),
|
||||||
screen: Value(screen),
|
screen: Value(screen),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,4 +199,4 @@ class FavoriteMiniAppDto {
|
|||||||
lastLoginAt: Value(lastLoginAt),
|
lastLoginAt: Value(lastLoginAt),
|
||||||
screen: Value(screen),
|
screen: Value(screen),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,4 +80,4 @@ class FavouriteDetailDto {
|
|||||||
chatId: Value(chatId),
|
chatId: Value(chatId),
|
||||||
sendTime: Value(sendTime),
|
sendTime: Value(sendTime),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,4 +127,4 @@ class FavouriteDto {
|
|||||||
isUploaded: Value(isUploaded),
|
isUploaded: Value(isUploaded),
|
||||||
urls: Value(urls),
|
urls: Value(urls),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,4 +218,4 @@ class GroupDto {
|
|||||||
topic: Value(topic),
|
topic: Value(topic),
|
||||||
rp: Value(rp),
|
rp: Value(rp),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,4 +134,4 @@ class MessageDto {
|
|||||||
flag: Value(flag),
|
flag: Value(flag),
|
||||||
cmid: Value(cmid),
|
cmid: Value(cmid),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,33 +31,33 @@ class PendingFriendRequestHistoryDto {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
'uid': uid,
|
'uid': uid,
|
||||||
'request_time': requestTime,
|
'request_time': requestTime,
|
||||||
'remarks': remarks,
|
'remarks': remarks,
|
||||||
'source': source,
|
'source': source,
|
||||||
'rs': rs,
|
'rs': rs,
|
||||||
};
|
};
|
||||||
|
|
||||||
PendingFriendRequestHistory toEntity() => PendingFriendRequestHistory(
|
PendingFriendRequestHistory toEntity() => PendingFriendRequestHistory(
|
||||||
id: id,
|
id: id,
|
||||||
uid: uid,
|
uid: uid,
|
||||||
requestTime: requestTime,
|
requestTime: requestTime,
|
||||||
remarks: remarks,
|
remarks: remarks,
|
||||||
source: source,
|
source: source,
|
||||||
rs: rs,
|
rs: rs,
|
||||||
);
|
);
|
||||||
|
|
||||||
factory PendingFriendRequestHistoryDto.fromEntity(
|
factory PendingFriendRequestHistoryDto.fromEntity(
|
||||||
PendingFriendRequestHistory history) =>
|
PendingFriendRequestHistory history,
|
||||||
PendingFriendRequestHistoryDto(
|
) => PendingFriendRequestHistoryDto(
|
||||||
id: history.id,
|
id: history.id,
|
||||||
uid: history.uid,
|
uid: history.uid,
|
||||||
requestTime: history.requestTime,
|
requestTime: history.requestTime,
|
||||||
remarks: history.remarks,
|
remarks: history.remarks,
|
||||||
source: history.source,
|
source: history.source,
|
||||||
rs: history.rs,
|
rs: history.rs,
|
||||||
);
|
);
|
||||||
|
|
||||||
PendingFriendRequestHistoriesCompanion toCompanion() =>
|
PendingFriendRequestHistoriesCompanion toCompanion() =>
|
||||||
PendingFriendRequestHistoriesCompanion(
|
PendingFriendRequestHistoriesCompanion(
|
||||||
@@ -68,4 +68,4 @@ class PendingFriendRequestHistoryDto {
|
|||||||
source: Value(source),
|
source: Value(source),
|
||||||
rs: Value(rs),
|
rs: Value(rs),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,4 +198,4 @@ class RecentMiniAppDto {
|
|||||||
lastLoginAt: Value(lastLoginAt),
|
lastLoginAt: Value(lastLoginAt),
|
||||||
screen: Value(screen),
|
screen: Value(screen),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,4 +106,4 @@ class RetryDto {
|
|||||||
createTime: Value(createTime),
|
createTime: Value(createTime),
|
||||||
addIndex: Value(addIndex),
|
addIndex: Value(addIndex),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,4 +85,4 @@ class SoundDto {
|
|||||||
channelGroupId: Value(channelGroupId),
|
channelGroupId: Value(channelGroupId),
|
||||||
isDefault: Value(isDefault),
|
isDefault: Value(isDefault),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,4 +71,4 @@ class TagDto {
|
|||||||
updatedAt: Value(updatedAt),
|
updatedAt: Value(updatedAt),
|
||||||
addIndex: Value(addIndex),
|
addIndex: Value(addIndex),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,4 +145,4 @@ class UserDto {
|
|||||||
userAlias: Value(userAlias),
|
userAlias: Value(userAlias),
|
||||||
hint: Value(hint),
|
hint: Value(hint),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,7 @@ class UserRequestHistoryDto {
|
|||||||
final int? status;
|
final int? status;
|
||||||
final int? createdAt;
|
final int? createdAt;
|
||||||
|
|
||||||
const UserRequestHistoryDto({
|
const UserRequestHistoryDto({required this.id, this.status, this.createdAt});
|
||||||
required this.id,
|
|
||||||
this.status,
|
|
||||||
this.createdAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory UserRequestHistoryDto.fromJson(Map<String, dynamic> json) =>
|
factory UserRequestHistoryDto.fromJson(Map<String, dynamic> json) =>
|
||||||
UserRequestHistoryDto(
|
UserRequestHistoryDto(
|
||||||
@@ -27,11 +23,8 @@ class UserRequestHistoryDto {
|
|||||||
'created_at': createdAt,
|
'created_at': createdAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
UserRequestHistory toEntity() => UserRequestHistory(
|
UserRequestHistory toEntity() =>
|
||||||
id: id,
|
UserRequestHistory(id: id, status: status, createdAt: createdAt);
|
||||||
status: status,
|
|
||||||
createdAt: createdAt,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory UserRequestHistoryDto.fromEntity(UserRequestHistory history) =>
|
factory UserRequestHistoryDto.fromEntity(UserRequestHistory history) =>
|
||||||
UserRequestHistoryDto(
|
UserRequestHistoryDto(
|
||||||
@@ -40,10 +33,9 @@ class UserRequestHistoryDto {
|
|||||||
createdAt: history.createdAt,
|
createdAt: history.createdAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
UserRequestHistoriesCompanion toCompanion() =>
|
UserRequestHistoriesCompanion toCompanion() => UserRequestHistoriesCompanion(
|
||||||
UserRequestHistoriesCompanion(
|
id: Value(id),
|
||||||
id: Value(id),
|
status: Value(status),
|
||||||
status: Value(status),
|
createdAt: Value(createdAt),
|
||||||
createdAt: Value(createdAt),
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -113,4 +113,4 @@ class WorkspaceDto {
|
|||||||
deletedAt: Value(deletedAt),
|
deletedAt: Value(deletedAt),
|
||||||
channelGroupId: Value(channelGroupId),
|
channelGroupId: Value(channelGroupId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,28 +6,30 @@ import '../../../domain/entities/user.dart';
|
|||||||
|
|
||||||
part 'get_profile_request.g.dart';
|
part 'get_profile_request.g.dart';
|
||||||
|
|
||||||
/// # /user/profile — 获取用户资料(GET 请求示例)
|
/// # /user/profile — 获取用户资料(GET 请求)
|
||||||
///
|
///
|
||||||
/// 演示:GET 请求 + 无 body 参数的模式。
|
/// GET 请求无 body,`toJson()` 结果自动作为 URL query parameters 发送。
|
||||||
/// GET 请求的 toJson() 结果会自动作为 URL query parameters 发送。
|
/// 如需 query 参数(如分页),直接在类中添加字段,生成器自动序列化。
|
||||||
///
|
///
|
||||||
/// ## 数据流位置
|
/// ## 数据流位置
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// UserRepositoryImpl.getProfile()
|
/// UserRepositoryImpl.getProfile()
|
||||||
/// → _client.executeRequest( ★ GetProfileRequest ★ ) ← 你在这里
|
/// → _client.executeRequest( ★ GetProfileRequest ★ ) ← 你在这里
|
||||||
/// → 服务端 GET /user/profile
|
/// → 服务端 GET /user/profile
|
||||||
/// → 响应 JSON → ★ ProfileData ★ ← 也在这里
|
/// → SDK 内部 ApiResponseWrapper 拆包 { code, message, data }
|
||||||
/// → ProfileData.toEntity() → User
|
/// → ★ ProfileResponse ★ = data 字段 ← 也在这里
|
||||||
|
/// → ProfileResponse.toEntity() → User
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
// Response DTO
|
// Response DTO
|
||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
/// 用户资料响应 DTO(只需反序列化,禁止生成无用的 toJson)
|
/// 用户资料接口的业务响应数据(对应服务端 `data` 字段)。
|
||||||
@JsonSerializable(createToJson: false)
|
///
|
||||||
class ProfileData {
|
/// `{ code, message }` 由 SDK 内部的 `ApiResponseWrapper` 统一处理。纯 Dart 类,无需任何注解。
|
||||||
|
class ProfileResponse {
|
||||||
final int uid;
|
final int uid;
|
||||||
final String uuid;
|
final String uuid;
|
||||||
@JsonKey(name: 'last_online')
|
@JsonKey(name: 'last_online')
|
||||||
@@ -54,7 +56,7 @@ class ProfileData {
|
|||||||
final int channelGroupId;
|
final int channelGroupId;
|
||||||
final String hint;
|
final String hint;
|
||||||
|
|
||||||
const ProfileData({
|
const ProfileResponse({
|
||||||
required this.uid,
|
required this.uid,
|
||||||
required this.uuid,
|
required this.uuid,
|
||||||
required this.lastOnline,
|
required this.lastOnline,
|
||||||
@@ -74,10 +76,6 @@ class ProfileData {
|
|||||||
required this.hint,
|
required this.hint,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ProfileData.fromJson(Map<String, dynamic> json) =>
|
|
||||||
_$ProfileDataFromJson(json);
|
|
||||||
|
|
||||||
/// DTO → Domain Entity
|
|
||||||
User toEntity() => User(
|
User toEntity() => User(
|
||||||
uid: uid,
|
uid: uid,
|
||||||
uuid: uuid,
|
uuid: uuid,
|
||||||
@@ -104,23 +102,12 @@ class ProfileData {
|
|||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
/// 获取用户资料请求(GET,无参数)
|
/// 获取用户资料请求(GET,无参数)
|
||||||
///
|
|
||||||
/// GET 请求无 body,toJson() 返回空 map。
|
|
||||||
/// 如需 query 参数(如分页),添加字段即可,
|
|
||||||
/// toJson() 会自动将字段序列化为 URL query string。
|
|
||||||
@ApiRequest(
|
@ApiRequest(
|
||||||
path: ApiPaths.userProfile,
|
path: ApiPaths.userProfile,
|
||||||
method: HttpMethod.get,
|
method: HttpMethod.get,
|
||||||
responseType: ProfileData,
|
responseType: ProfileResponse,
|
||||||
)
|
)
|
||||||
@JsonSerializable()
|
class GetProfileRequest extends ApiRequestable<ProfileResponse>
|
||||||
class GetProfileRequest extends ApiRequestable<ProfileData>
|
|
||||||
with _$GetProfileRequestApi {
|
with _$GetProfileRequestApi {
|
||||||
GetProfileRequest();
|
GetProfileRequest();
|
||||||
|
|
||||||
factory GetProfileRequest.fromJson(Map<String, dynamic> json) =>
|
|
||||||
_$GetProfileRequestFromJson(json);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Map<String, dynamic> toJson() => _$GetProfileRequestToJson(this);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,17 +12,20 @@ part 'login_request.g.dart';
|
|||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// AuthRepositoryImpl.login(email, password)
|
/// AuthRepositoryImpl.login(email, password)
|
||||||
/// → _client.executeRequest( ★ LoginRequest ★ ) ← 你在这里
|
/// → _client.executeRequest( ★ LoginRequest ★ ) ← 你在这里
|
||||||
/// → 服务端 POST /auth/login
|
/// → 服务端 POST /auth/login
|
||||||
/// → 响应 JSON → ★ LoginResponse ★ ← 也在这里
|
/// → SDK 内部 ApiResponseWrapper 拆包 { code, message, data }
|
||||||
/// → LoginResponse.toEntity() → User
|
/// → ★ LoginResponse ★ = data 字段,T in APIResponseWrapper<T> ← 也在这里
|
||||||
|
/// → LoginResponse.toEntity() → User
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
// Response DTO
|
// Response DTO
|
||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
@JsonSerializable(createToJson: false)
|
/// 登录响应中的用户档案,嵌套在 [LoginResponse.profile] 中。
|
||||||
|
///
|
||||||
|
/// 纯 Dart 类,无需任何注解。`_$LoginProfileFromJson` 由生成器从 `@ApiRequest` 声明中自动推导生成。
|
||||||
class LoginProfile {
|
class LoginProfile {
|
||||||
final int uid;
|
final int uid;
|
||||||
final String uuid;
|
final String uuid;
|
||||||
@@ -70,9 +73,6 @@ class LoginProfile {
|
|||||||
required this.hint,
|
required this.hint,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory LoginProfile.fromJson(Map<String, dynamic> json) =>
|
|
||||||
_$LoginProfileFromJson(json);
|
|
||||||
|
|
||||||
User toEntity() => User(
|
User toEntity() => User(
|
||||||
uid: uid,
|
uid: uid,
|
||||||
uuid: uuid,
|
uuid: uuid,
|
||||||
@@ -94,8 +94,11 @@ class LoginProfile {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonSerializable(createToJson: false, explicitToJson: true)
|
/// 登录接口的业务响应数据(对应服务端 `data` 字段,即 T in `APIResponseWrapper<T>`)。
|
||||||
class LoginData {
|
///
|
||||||
|
/// `{ code, message }` 由 SDK 内部的 `ApiResponseWrapper` 统一处理,
|
||||||
|
/// App 层只接触此类,不感知 envelope 结构。纯 Dart 类,无需任何注解。
|
||||||
|
class LoginResponse {
|
||||||
@JsonKey(name: 'account_id')
|
@JsonKey(name: 'account_id')
|
||||||
final String accountId;
|
final String accountId;
|
||||||
final LoginProfile profile;
|
final LoginProfile profile;
|
||||||
@@ -109,7 +112,7 @@ class LoginData {
|
|||||||
@JsonKey(name: 'login_data')
|
@JsonKey(name: 'login_data')
|
||||||
final String loginData;
|
final String loginData;
|
||||||
|
|
||||||
const LoginData({
|
const LoginResponse({
|
||||||
required this.accountId,
|
required this.accountId,
|
||||||
required this.profile,
|
required this.profile,
|
||||||
required this.nonce,
|
required this.nonce,
|
||||||
@@ -119,52 +122,28 @@ class LoginData {
|
|||||||
required this.loginData,
|
required this.loginData,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory LoginData.fromJson(Map<String, dynamic> json) =>
|
|
||||||
_$LoginDataFromJson(json);
|
|
||||||
|
|
||||||
User toEntity() => profile.toEntity();
|
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
|
// Request
|
||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 登录请求
|
||||||
|
///
|
||||||
|
/// `@ApiRequest` 一个注解搞定一切:
|
||||||
|
/// - mixin 自动生成 path / method / requestType / includeToken / toJson
|
||||||
|
/// - parameters getter 自动注册 `_$LoginResponseFromJson` 到 SDK 全局注册表
|
||||||
@ApiRequest(
|
@ApiRequest(
|
||||||
path: ApiPaths.authLogin,
|
path: ApiPaths.authLogin,
|
||||||
method: HttpMethod.post,
|
method: HttpMethod.post,
|
||||||
responseType: LoginResponse,
|
responseType: LoginResponse,
|
||||||
requestType: ApiRequestType.login,
|
requestType: ApiRequestType.login,
|
||||||
)
|
)
|
||||||
@JsonSerializable()
|
|
||||||
class LoginRequest extends ApiRequestable<LoginResponse>
|
class LoginRequest extends ApiRequestable<LoginResponse>
|
||||||
with _$LoginRequestApi {
|
with _$LoginRequestApi {
|
||||||
final String email;
|
final String email;
|
||||||
final String password;
|
final String password;
|
||||||
|
|
||||||
LoginRequest({required this.email, required this.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';
|
import '../../../core/foundation/api_paths.dart';
|
||||||
|
|
||||||
/// # /auth/logout — 登出接口(无响应数据示例)
|
part 'logout_request.g.dart';
|
||||||
|
|
||||||
|
/// # /auth/logout — 登出接口(无响应数据)
|
||||||
///
|
///
|
||||||
/// 演示:POST 请求 + 无 Response DTO 的模式。
|
|
||||||
/// 服务端返回 `{"code": 0, "message": "ok"}` 无 data 字段,
|
/// 服务端返回 `{"code": 0, "message": "ok"}` 无 data 字段,
|
||||||
/// `executeRequest` 返回 null,调用方直接 await 即可。
|
/// `executeRequest` 返回 null,调用方直接 await 即可。
|
||||||
///
|
///
|
||||||
/// 此接口不使用 @ApiRequest 注解,直接实现 ApiRequestable,
|
/// `responseType` 省略 → 生成器跳过 `fromJson` 注册,mixin 泛型为 `void`。
|
||||||
/// 演示手动实现方式(适用于不需要代码生成器的简单接口)。
|
|
||||||
///
|
///
|
||||||
/// ## 数据流位置
|
/// ## 数据流位置
|
||||||
///
|
///
|
||||||
@@ -17,16 +17,9 @@ import '../../../core/foundation/api_paths.dart';
|
|||||||
/// AuthRepositoryImpl.logout()
|
/// AuthRepositoryImpl.logout()
|
||||||
/// → _client.executeRequest( ★ LogoutRequest ★ ) ← 你在这里
|
/// → _client.executeRequest( ★ LogoutRequest ★ ) ← 你在这里
|
||||||
/// → 服务端 POST /auth/logout
|
/// → 服务端 POST /auth/logout
|
||||||
/// → 响应 {"code": 0, "message": "ok"} → null
|
/// → 响应 {"code": 0, "message": "ok"} → null(无 data)
|
||||||
/// ```
|
/// ```
|
||||||
class LogoutRequest extends ApiRequestable<void> {
|
@ApiRequest(path: ApiPaths.authLogout, method: HttpMethod.post)
|
||||||
@override
|
class LogoutRequest extends ApiRequestable<void> with _$LogoutRequestApi {
|
||||||
String get path => ApiPaths.authLogout;
|
LogoutRequest();
|
||||||
|
|
||||||
@override
|
|
||||||
HttpMethod get method => HttpMethod.post;
|
|
||||||
|
|
||||||
/// 登出不需要请求体参数
|
|
||||||
@override
|
|
||||||
Map<String, dynamic> toJson() => {};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,7 @@ part 'upload_file_request.g.dart';
|
|||||||
// Response DTO
|
// Response DTO
|
||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
/// 文件上传响应 DTO(只需反序列化,禁止生成无用的 toJson)
|
/// 文件上传接口的业务响应数据(对应服务端 `data` 字段)。纯 Dart 类,无需任何注解。
|
||||||
@JsonSerializable(createToJson: false)
|
|
||||||
class UploadResult {
|
class UploadResult {
|
||||||
final String url;
|
final String url;
|
||||||
|
|
||||||
@@ -41,9 +40,6 @@ class UploadResult {
|
|||||||
final String fileId;
|
final String fileId;
|
||||||
|
|
||||||
const UploadResult({required this.url, required this.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 上传请求
|
/// FormData 上传请求
|
||||||
///
|
///
|
||||||
/// 上传到自有后端 `/upload/file`,响应为标准 `{ code, message, data }` 信封。
|
/// 上传到自有后端 `/upload/file`,响应为标准 `{ code, message, data }` 格式。
|
||||||
/// 无需 override `decodeResponse`。
|
/// 无需 override `decodeResponse`。
|
||||||
@ApiRequest(
|
@ApiRequest(
|
||||||
path: ApiPaths.uploadFile,
|
path: ApiPaths.uploadFile,
|
||||||
@@ -97,7 +93,7 @@ class S3UploadResponse {
|
|||||||
/// - path 为完整的 presigned URL(SDK 检测到 http 开头不拼 baseURL)
|
/// - path 为完整的 presigned URL(SDK 检测到 http 开头不拼 baseURL)
|
||||||
/// - uploadData 为 Uint8List 二进制数据
|
/// - uploadData 为 Uint8List 二进制数据
|
||||||
/// - 自定义 headers(Content-Type: application/octet-stream)
|
/// - 自定义 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> {
|
class S3UploadRequest extends ApiRequestable<S3UploadResponse> {
|
||||||
final Uint8List data;
|
final Uint8List data;
|
||||||
final String presignedURL;
|
final String presignedURL;
|
||||||
@@ -115,8 +111,8 @@ class S3UploadRequest extends ApiRequestable<S3UploadResponse> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, String>? get customHeaders => {
|
Map<String, String>? get customHeaders => {
|
||||||
'Content-Type': 'application/octet-stream',
|
'Content-Type': 'application/octet-stream',
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() => {};
|
Map<String, dynamic> toJson() => {};
|
||||||
@@ -125,7 +121,7 @@ class S3UploadRequest extends ApiRequestable<S3UploadResponse> {
|
|||||||
@override
|
@override
|
||||||
Object? get uploadData => data;
|
Object? get uploadData => data;
|
||||||
|
|
||||||
/// S3 响应不走标准 { code, message, data } 信封,需要自定义解码
|
/// S3 响应不走标准 { code, message, data } 格式,需要自定义解码
|
||||||
///
|
///
|
||||||
/// 可能的响应:
|
/// 可能的响应:
|
||||||
/// - 204 No Content(空 body)→ 成功
|
/// - 204 No Content(空 body)→ 成功
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import '../remote/logout_request.dart';
|
|||||||
/// 认证 Repository 实现
|
/// 认证 Repository 实现
|
||||||
///
|
///
|
||||||
/// implements [AuthRepository] 接口(domain/repositories/ 中定义)。
|
/// implements [AuthRepository] 接口(domain/repositories/ 中定义)。
|
||||||
/// 直接使用 [ApiClient] 发送请求,将 DTO 转为 Domain Entity。
|
/// 直接使用 [NetworksSdkApi] 发送请求,将 DTO 转为 Domain Entity。
|
||||||
/// 后续可加 Local DataSource 实现离线缓存。
|
/// 后续可加 Local DataSource 实现离线缓存。
|
||||||
///
|
///
|
||||||
/// ## 数据流位置
|
/// ## 数据流位置
|
||||||
@@ -16,18 +16,22 @@ import '../remote/logout_request.dart';
|
|||||||
/// ```
|
/// ```
|
||||||
/// LoginUseCase.execute(email, password)
|
/// LoginUseCase.execute(email, password)
|
||||||
/// → ★ AuthRepositoryImpl.login() ★ ← 你在这里
|
/// → ★ AuthRepositoryImpl.login() ★ ← 你在这里
|
||||||
/// → ApiClient.executeRequest(LoginRequest)
|
/// → NetworksSdkApi.executeRequest(LoginRequest)
|
||||||
/// → 服务端 POST /auth/login
|
/// → 服务端 POST /auth/login
|
||||||
/// ← LoginData(Response DTO)
|
/// ← LoginResponse(SDK 已拆包 { code, message, data } envelope)
|
||||||
/// → onTokenUpdate(token) ← 回调写入 Token
|
/// → _onTokenUpdate(accessToken) ← 回调写入 Token
|
||||||
/// ← LoginData.toEntity() → User ← DTO → Entity 转换在这里
|
/// ← LoginResponse.toEntity() → User ← DTO → Entity 转换在这里
|
||||||
/// ← User(Domain Entity)
|
/// ← User(Domain Entity)
|
||||||
/// ```
|
/// ```
|
||||||
class AuthRepositoryImpl implements AuthRepository {
|
class AuthRepositoryImpl implements AuthRepository {
|
||||||
final NetworksSdkApi _client;
|
final NetworksSdkApi _client;
|
||||||
final void Function(String?) _onTokenUpdate;
|
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
|
@override
|
||||||
Future<User> login({required String email, required String password}) async {
|
Future<User> login({required String email, required String password}) async {
|
||||||
@@ -39,7 +43,7 @@ class AuthRepositoryImpl implements AuthRepository {
|
|||||||
throw Exception('Login failed: empty response');
|
throw Exception('Login failed: empty response');
|
||||||
}
|
}
|
||||||
|
|
||||||
_onTokenUpdate(loginResponse.data.accessToken);
|
_onTokenUpdate(loginResponse.accessToken);
|
||||||
|
|
||||||
return loginResponse.toEntity();
|
return loginResponse.toEntity();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,4 +64,4 @@ class CallLog {
|
|||||||
isRead: isRead ?? this.isRead,
|
isRead: isRead ?? this.isRead,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,4 +197,4 @@ class Chat {
|
|||||||
localPermission: localPermission ?? this.localPermission,
|
localPermission: localPermission ?? this.localPermission,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,4 +97,4 @@ class ChatBot {
|
|||||||
tips: tips ?? this.tips,
|
tips: tips ?? this.tips,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,4 +45,4 @@ class ChatCategory {
|
|||||||
deletedAt: deletedAt ?? this.deletedAt,
|
deletedAt: deletedAt ?? this.deletedAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,4 +109,4 @@ class DiscoverMiniApp {
|
|||||||
screen: screen ?? this.screen,
|
screen: screen ?? this.screen,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,4 +109,4 @@ class ExploreMiniApp {
|
|||||||
screen: screen ?? this.screen,
|
screen: screen ?? this.screen,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,4 +109,4 @@ class FavoriteMiniApp {
|
|||||||
screen: screen ?? this.screen,
|
screen: screen ?? this.screen,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,4 +69,4 @@ class Favourite {
|
|||||||
urls: urls ?? this.urls,
|
urls: urls ?? this.urls,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,4 +41,4 @@ class FavouriteDetail {
|
|||||||
sendTime: sendTime ?? this.sendTime,
|
sendTime: sendTime ?? this.sendTime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,4 +121,4 @@ class Group {
|
|||||||
rp: rp ?? this.rp,
|
rp: rp ?? this.rp,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,4 +73,4 @@ class Message {
|
|||||||
cmid: cmid ?? this.cmid,
|
cmid: cmid ?? this.cmid,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,4 +33,4 @@ class PendingFriendRequestHistory {
|
|||||||
rs: rs ?? this.rs,
|
rs: rs ?? this.rs,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,4 +109,4 @@ class RecentMiniApp {
|
|||||||
screen: screen ?? this.screen,
|
screen: screen ?? this.screen,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,4 +57,4 @@ class Retry {
|
|||||||
addIndex: addIndex ?? this.addIndex,
|
addIndex: addIndex ?? this.addIndex,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,4 +45,4 @@ class Sound {
|
|||||||
isDefault: isDefault ?? this.isDefault,
|
isDefault: isDefault ?? this.isDefault,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,4 +37,4 @@ class Tag {
|
|||||||
addIndex: addIndex ?? this.addIndex,
|
addIndex: addIndex ?? this.addIndex,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,4 +91,4 @@ class User {
|
|||||||
hint: hint ?? this.hint,
|
hint: hint ?? this.hint,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,21 +4,13 @@ class UserRequestHistory {
|
|||||||
final int? status;
|
final int? status;
|
||||||
final int? createdAt;
|
final int? createdAt;
|
||||||
|
|
||||||
const UserRequestHistory({
|
const UserRequestHistory({required this.id, this.status, this.createdAt});
|
||||||
required this.id,
|
|
||||||
this.status,
|
|
||||||
this.createdAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
UserRequestHistory copyWith({
|
UserRequestHistory copyWith({int? id, int? status, int? createdAt}) {
|
||||||
int? id,
|
|
||||||
int? status,
|
|
||||||
int? createdAt,
|
|
||||||
}) {
|
|
||||||
return UserRequestHistory(
|
return UserRequestHistory(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,4 +61,4 @@ class Workspace {
|
|||||||
channelGroupId: channelGroupId ?? this.channelGroupId,
|
channelGroupId: channelGroupId ?? this.channelGroupId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,51 +6,20 @@ import 'package:im_app/app/di/db_provider.dart';
|
|||||||
import 'package:im_app/data/local/drift/app_database.dart';
|
import 'package:im_app/data/local/drift/app_database.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
import 'chat_db_test_state.dart';
|
||||||
|
|
||||||
|
export 'chat_db_test_state.dart';
|
||||||
|
|
||||||
part 'chat_db_test_view_model.g.dart';
|
part 'chat_db_test_view_model.g.dart';
|
||||||
|
|
||||||
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 = '',
|
|
||||||
});
|
|
||||||
|
|
||||||
ChatDbTestState copyWith({
|
|
||||||
bool? testStarted,
|
|
||||||
List<TestResult>? testResults,
|
|
||||||
String? currentState,
|
|
||||||
}) => ChatDbTestState(
|
|
||||||
testStarted: testStarted ?? this.testStarted,
|
|
||||||
testResults: testResults ?? this.testResults,
|
|
||||||
currentState: currentState ?? this.currentState,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@riverpod
|
@riverpod
|
||||||
class ChatDbTestViewModel extends _$ChatDbTestViewModel {
|
class ChatDbTestViewModel extends _$ChatDbTestViewModel {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ChatDbTestState build() {
|
ChatDbTestState build() {
|
||||||
// 这里就是 onInit
|
// 这里就是 onInit
|
||||||
final List<TestResult> testResults = List.generate(
|
final List<TestResult> testResults = List.generate(
|
||||||
1000,
|
1000,
|
||||||
(i) => TestResult(
|
(i) => TestResult(
|
||||||
title: '用户 ${Random().nextInt(9999)}',
|
title: '用户 ${Random().nextInt(9999)}',
|
||||||
subtitle: 'uid: ${Random().nextInt(999999)}',
|
subtitle: 'uid: ${Random().nextInt(999999)}',
|
||||||
duration: '${Random().nextInt(500)}ms',
|
duration: '${Random().nextInt(500)}ms',
|
||||||
@@ -59,17 +28,16 @@ class ChatDbTestViewModel extends _$ChatDbTestViewModel {
|
|||||||
return ChatDbTestState(testResults: testResults);
|
return ChatDbTestState(testResults: testResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 导航(Demo 按钮,正式开发后随 UI 一并替换) ──────────────────────────
|
// ── 操作(Demo 按钮,正式开发后随 UI 一并替换) ──────────────────────────
|
||||||
|
|
||||||
/// 开始测试
|
/// 切换测试状态(开始 / 停止)
|
||||||
void startDBTest(BuildContext context) {
|
void toggleDBTest() {
|
||||||
state = state.copyWith(testStarted: true, currentState: '开始测试');
|
if (state.testStarted) {
|
||||||
_testDBInsert();
|
state = state.copyWith(testStarted: false, currentState: '结束测试');
|
||||||
}
|
} else {
|
||||||
|
state = state.copyWith(testStarted: true, currentState: '开始测试');
|
||||||
/// 结束测试
|
_testDBInsert();
|
||||||
void stopDBTest(BuildContext context) {
|
}
|
||||||
state = state.copyWith(testStarted: false, currentState: '结束测试');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _testDBInsert() async {
|
Future<void> _testDBInsert() async {
|
||||||
@@ -85,10 +53,8 @@ class ChatDbTestViewModel extends _$ChatDbTestViewModel {
|
|||||||
for (var i = 0; i < count; i += chunkSize) {
|
for (var i = 0; i < count; i += chunkSize) {
|
||||||
final chunk = List.generate(
|
final chunk = List.generate(
|
||||||
chunkSize.clamp(0, count - i),
|
chunkSize.clamp(0, count - i),
|
||||||
(j) => UsersCompanion.insert(
|
(j) =>
|
||||||
uid: i + j,
|
UsersCompanion.insert(uid: i + j, nickname: Value('User ${i + j}')),
|
||||||
nickname: Value('User ${i + j}'),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await db.batchInsertOrReplace<DriftUser>(chunk);
|
await db.batchInsertOrReplace<DriftUser>(chunk);
|
||||||
@@ -97,13 +63,13 @@ class ChatDbTestViewModel extends _$ChatDbTestViewModel {
|
|||||||
// 让出主线程
|
// 让出主线程
|
||||||
await Future.delayed(Duration.zero);
|
await Future.delayed(Duration.zero);
|
||||||
|
|
||||||
debugPrint('已完成: $completed / $count (${stopwatch.elapsedMilliseconds}ms)');
|
debugPrint(
|
||||||
|
'已完成: $completed / $count (${stopwatch.elapsedMilliseconds}ms)',
|
||||||
|
);
|
||||||
|
|
||||||
// 更新 UI 状态
|
// 更新 UI 状态
|
||||||
if (ref.mounted) {
|
if (ref.mounted) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(currentState: '已插入 $completed / $count 条');
|
||||||
currentState: '已插入 $completed / $count 条',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,4 +81,4 @@ class ChatDbTestViewModel extends _$ChatDbTestViewModel {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:im_app/features/chat/presentation/chat_db_test_view_model.dart';
|
import 'package:im_app/features/chat/presentation/chat_db_test_view_model.dart';
|
||||||
|
|
||||||
import '../../../core/ui/components/app_button.dart';
|
import '../../../core/ui/components/app_button.dart';
|
||||||
import '../presentation/chat_view_model.dart';
|
|
||||||
|
|
||||||
/// 聊天页(Demo 按钮)
|
/// 数据库性能测试页(Demo)
|
||||||
///
|
///
|
||||||
/// 包含五个演示按钮,覆盖 go_router 的常见导航场景:
|
/// 批量插入 10000 条用户记录,验证 Drift 批量写入性能。
|
||||||
/// - 「切换 Tab」 — go,替换历史,不可返回
|
/// 所有操作通过 [ChatDbTestViewModel] 处理,View 只负责渲染。
|
||||||
/// - 「有参 push(extra)」 — push + extra(Dart Record),可返回
|
/// 正式开发后此页面将被删除。
|
||||||
/// - 「有参 push(路径参数)」— push + URL 内嵌 id,可返回
|
|
||||||
/// - 「无参 push」 — push,可返回
|
|
||||||
/// - 「退出登录」 — 守卫自动重定向到 /login
|
|
||||||
///
|
|
||||||
/// 所有操作通过 [ChatViewModel] 处理,View 不直接调用路由。
|
|
||||||
/// 正式开发后替换为会话列表,按钮相关代码一并清除。
|
|
||||||
class ChatDbTestPage extends ConsumerWidget {
|
class ChatDbTestPage extends ConsumerWidget {
|
||||||
const ChatDbTestPage({super.key});
|
const ChatDbTestPage({super.key});
|
||||||
|
|
||||||
@@ -25,7 +18,7 @@ class ChatDbTestPage extends ConsumerWidget {
|
|||||||
final state = ref.watch(chatDbTestViewModelProvider);
|
final state = ref.watch(chatDbTestViewModelProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('测试数据库'), ),
|
appBar: AppBar(title: const Text('测试数据库')),
|
||||||
body: Column(
|
body: Column(
|
||||||
mainAxisSize: MainAxisSize.max,
|
mainAxisSize: MainAxisSize.max,
|
||||||
spacing: 16,
|
spacing: 16,
|
||||||
@@ -37,18 +30,15 @@ class ChatDbTestPage extends ConsumerWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
AppButton.inverse(
|
AppButton.inverse(
|
||||||
label: state.testStarted ? '结束' : '开始',
|
label: state.buttonLabel,
|
||||||
onPressed: () => state.testStarted ? vm.stopDBTest(context) : vm.startDBTest(context),
|
onPressed: () => vm.toggleDBTest(),
|
||||||
),
|
),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(state.currentState, textAlign: TextAlign.end),
|
||||||
state.currentState,
|
),
|
||||||
textAlign: TextAlign.end,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
],
|
||||||
)
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
@@ -63,7 +53,7 @@ class ChatDbTestPage extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../../core/ui/base/context_theme_ext.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 加载完整会话数据。
|
/// 将 [conversationId] 传给对应的 Riverpod `.family` provider 加载完整会话数据。
|
||||||
/// 构造参数保持不变,数据来源从 `extra` 换成 provider 即可。
|
/// 构造参数保持不变,数据来源从 `extra` 换成 provider 即可。
|
||||||
class ChatDetailPage extends StatelessWidget {
|
class ChatDetailPage extends ConsumerWidget {
|
||||||
const ChatDetailPage({
|
const ChatDetailPage({
|
||||||
super.key,
|
super.key,
|
||||||
required this.conversationId,
|
required this.conversationId,
|
||||||
@@ -23,7 +24,7 @@ class ChatDetailPage extends StatelessWidget {
|
|||||||
final String title;
|
final String title;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final s = context.styles;
|
final s = context.styles;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ class ChatPage extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final vm = ref.read(chatViewModelProvider.notifier);
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('聊天')),
|
appBar: AppBar(title: const Text('聊天')),
|
||||||
body: Center(
|
body: Center(
|
||||||
@@ -32,36 +30,48 @@ class ChatPage extends ConsumerWidget {
|
|||||||
// 切换 Tab:用 go,替换整个历史栈,不可返回
|
// 切换 Tab:用 go,替换整个历史栈,不可返回
|
||||||
AppButton.inverse(
|
AppButton.inverse(
|
||||||
label: '切换 Tab(go)',
|
label: '切换 Tab(go)',
|
||||||
onPressed: () => vm.goToContact(context),
|
onPressed: () =>
|
||||||
|
ref.read(chatViewModelProvider.notifier).goToContact(context),
|
||||||
),
|
),
|
||||||
// 带参数 push:extra 传 Dart Record,适合已有对象的场景
|
// 带参数 push:extra 传 Dart Record,适合已有对象的场景
|
||||||
AppButton.inverse(
|
AppButton.inverse(
|
||||||
label: '有参 push(extra)',
|
label: '有参 push(extra)',
|
||||||
onPressed: () => vm.pushChatDetailWithExtra(context),
|
onPressed: () => ref
|
||||||
|
.read(chatViewModelProvider.notifier)
|
||||||
|
.pushChatDetailWithExtra(context),
|
||||||
),
|
),
|
||||||
// 带参数 push:id 内嵌在路径中,适合需要深链接 / 分享的场景
|
// 带参数 push:id 内嵌在路径中,适合需要深链接 / 分享的场景
|
||||||
AppButton.inverse(
|
AppButton.inverse(
|
||||||
label: '有参 push(路径参数)',
|
label: '有参 push(路径参数)',
|
||||||
onPressed: () => vm.pushChatDetailById(context),
|
onPressed: () => ref
|
||||||
|
.read(chatViewModelProvider.notifier)
|
||||||
|
.pushChatDetailById(context),
|
||||||
),
|
),
|
||||||
// 无参 push:压栈,自动显示返回按钮,不切 Tab
|
// 无参 push:压栈,自动显示返回按钮,不切 Tab
|
||||||
AppButton.inverse(
|
AppButton.inverse(
|
||||||
label: '无参 push',
|
label: '无参 push',
|
||||||
onPressed: () => vm.pushSettingsTheme(context),
|
onPressed: () => ref
|
||||||
|
.read(chatViewModelProvider.notifier)
|
||||||
|
.pushSettingsTheme(context),
|
||||||
),
|
),
|
||||||
// 无参 go:替换历史,切换到对应 Tab,TabBar 可见,不可返回
|
// 无参 go:替换历史,切换到对应 Tab,TabBar 可见,不可返回
|
||||||
AppButton.inverse(
|
AppButton.inverse(
|
||||||
label: '无参 go',
|
label: '无参 go',
|
||||||
onPressed: () => vm.goToSettings(context),
|
onPressed: () => ref
|
||||||
|
.read(chatViewModelProvider.notifier)
|
||||||
|
.goToSettings(context),
|
||||||
),
|
),
|
||||||
AppButton.inverse(
|
AppButton.inverse(
|
||||||
label: '测试数据库性能',
|
label: '测试数据库性能',
|
||||||
onPressed: () => vm.goToDatabaseTest(context),
|
onPressed: () => ref
|
||||||
|
.read(chatViewModelProvider.notifier)
|
||||||
|
.goToDatabaseTest(context),
|
||||||
),
|
),
|
||||||
AppButton.secondary(
|
AppButton.secondary(
|
||||||
label: '退出登录',
|
label: '退出登录',
|
||||||
fullWidth: false,
|
fullWidth: false,
|
||||||
onPressed: () => vm.logout(),
|
onPressed: () =>
|
||||||
|
ref.read(chatViewModelProvider.notifier).logout(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
/// 联系人页占位
|
/// 联系人页占位
|
||||||
///
|
///
|
||||||
/// 待 contact 功能开发后替换为实际内容。
|
/// 待 contact 功能开发后替换为实际内容。
|
||||||
class ContactPage extends StatelessWidget {
|
class ContactPage extends ConsumerWidget {
|
||||||
const ContactPage({super.key});
|
const ContactPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return const Scaffold();
|
return const Scaffold();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import '../usecases/login_usecase.dart';
|
|||||||
/// ViewModel Provider 由 `@riverpod` 注解自动生成,不在此文件中。
|
/// ViewModel Provider 由 `@riverpod` 注解自动生成,不在此文件中。
|
||||||
///
|
///
|
||||||
/// Auth 模块的 DI 链路:Repository → UseCase(按需)。
|
/// Auth 模块的 DI 链路:Repository → UseCase(按需)。
|
||||||
/// app/di/ 只提供 SDK 基础设施(apiConfig / apiClient / socketManager / storageApi),
|
/// app/di/ 只提供 SDK 基础设施(apiConfig / networkSdkApi / socketManager / storageApi),
|
||||||
/// 业务模块的 Provider 内聚在 features/{模块}/di/ 下。
|
/// 业务模块的 Provider 内聚在 features/{模块}/di/ 下。
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
@@ -21,7 +21,7 @@ import '../usecases/login_usecase.dart';
|
|||||||
/// → ref.read(authRepositoryProvider) ← di/ 手动装配
|
/// → ref.read(authRepositoryProvider) ← di/ 手动装配
|
||||||
/// → ref.read(socketManagerProvider) ← app/di/ 手动装配
|
/// → ref.read(socketManagerProvider) ← app/di/ 手动装配
|
||||||
/// → ref.read(apiConfigProvider) ← app/di/ 手动装配
|
/// → ref.read(apiConfigProvider) ← app/di/ 手动装配
|
||||||
/// → ref.read(apiClientProvider) ← app/di/ 手动装配
|
/// → ref.read(networkSdkApiProvider) ← app/di/ 手动装配
|
||||||
/// → ref.read(storageSdkProvider) ← app/di/ 手动装配
|
/// → ref.read(storageSdkProvider) ← app/di/ 手动装配
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
|||||||
// TODO: final secureStorage = ref.read(secureStorageProvider);
|
// TODO: final secureStorage = ref.read(secureStorageProvider);
|
||||||
|
|
||||||
return AuthRepositoryImpl(
|
return AuthRepositoryImpl(
|
||||||
client: ref.read(networkSdkApiProvider), // 直接注入 ApiClient
|
client: ref.read(networkSdkApiProvider), // 注入 Facade 接口
|
||||||
onTokenUpdate: (token) {
|
onTokenUpdate: (token) {
|
||||||
apiConfig.updateToken(token); // 内存(network_sdk)
|
apiConfig.updateToken(token); // 内存(network_sdk)
|
||||||
// TODO: secureStorage.saveToken(token); // 持久化(crypto_sdk)
|
// TODO: secureStorage.saveToken(token); // 持久化(crypto_sdk)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:im_app/data/models/user_dto.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/db_provider.dart';
|
||||||
|
import 'package:im_app/data/models/user_dto.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:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:storage_sdk/storage_sdk.dart';
|
import 'package:storage_sdk/storage_sdk.dart';
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ part 'login_view_model.g.dart';
|
|||||||
/// loginViewModelProvider ← @riverpod 自动生成(本文件)
|
/// loginViewModelProvider ← @riverpod 自动生成(本文件)
|
||||||
/// → ref.read(loginUseCaseProvider) ← di/ 手动装配
|
/// → ref.read(loginUseCaseProvider) ← di/ 手动装配
|
||||||
/// → ref.read(authRepositoryProvider) ← 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
|
/// → LoginUseCase.execute() ← 格式校验 + 调 Repository
|
||||||
/// → AuthRepository.login()
|
/// → AuthRepository.login()
|
||||||
/// → _client.executeRequest(LoginRequest)
|
/// → _client.executeRequest(LoginRequest)
|
||||||
/// ← LoginData → User
|
/// ← LoginResponse → User
|
||||||
/// ← User
|
/// ← User
|
||||||
/// → state = state.copyWith(user: user) ← 更新状态
|
/// → state = state.copyWith(user: user) ← 更新状态
|
||||||
/// View: ref.watch → 自动 rebuild ← UI 刷新
|
/// View: ref.watch → 自动 rebuild ← UI 刷新
|
||||||
@@ -59,24 +59,56 @@ class LoginViewModel extends _$LoginViewModel {
|
|||||||
/// 仅用于演示路由守卫行为,正式 UI 就绪后删除此方法,改用 [login]。
|
/// 仅用于演示路由守卫行为,正式 UI 就绪后删除此方法,改用 [login]。
|
||||||
/// 正式 [login] 成功后同样需要调用 [AuthNotifier.login] 更新守卫状态。
|
/// 正式 [login] 成功后同样需要调用 [AuthNotifier.login] 更新守卫状态。
|
||||||
Future<void> demoLogin() async {
|
Future<void> demoLogin() async {
|
||||||
final storageApi = ref.read(storageSdkProvider);
|
// 防止连点重入:第一次调用未完成前忽略后续调用
|
||||||
final storageLifeCycle = storageApi as StorageSdkLifecycle;
|
if (state.isLoading) return;
|
||||||
final provider = ref.read(authNotifierProvider);
|
state = state.copyWith(isLoading: true, error: null);
|
||||||
|
|
||||||
// Read mock response from assets
|
try {
|
||||||
final String raw = await rootBundle.loadString('assets/loginData.json');
|
final storageApi = ref.read(storageSdkProvider);
|
||||||
final Map<String, dynamic> json = jsonDecode(raw);
|
final storageLifeCycle = storageApi as StorageSdkLifecycle;
|
||||||
|
|
||||||
// Parse into LoginData (nested under 'data' key)
|
// 读取 mock 数据(loginData.json 结构: { code, message, data: {...} })
|
||||||
final loginResponse = LoginResponse.fromJson(json);
|
// 手动拆包 data 字段,对应 SDK 内部 ApiResponseWrapper 的行为
|
||||||
final user = loginResponse.data.toEntity();
|
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?,
|
||||||
|
channelId: profile['channel_id'] as int,
|
||||||
|
channelGroupId: profile['channel_group_id'] as int,
|
||||||
|
hint: profile['hint'] as String,
|
||||||
|
);
|
||||||
|
|
||||||
provider.login();
|
// 先完成 DB 操作,再标记登录状态(失败时不会误标为已登录)
|
||||||
// Open database for the user
|
await storageLifeCycle.openDatabase(user.uid);
|
||||||
await storageLifeCycle.openDatabase(user.uid);
|
final userCompanion = UserDto.fromEntity(user).toCompanion();
|
||||||
///TODO: User 和 DTO和数据库之间转换
|
await storageApi.insertOrReplace(userCompanion);
|
||||||
final userCompanion = UserDto.fromEntity(user).toCompanion();
|
|
||||||
storageApi.insert(userCompanion);
|
// 全部成功后再更新登录状态,触发路由守卫重定向
|
||||||
|
// 注意:login() 触发导航后 provider 随即被 dispose,之后不能再写 state
|
||||||
|
if (!ref.mounted) return;
|
||||||
|
ref.read(authNotifierProvider).login();
|
||||||
|
} catch (e) {
|
||||||
|
// 导航已发生时 provider 已被 dispose,静默丢弃,不再写 state
|
||||||
|
if (!ref.mounted) return;
|
||||||
|
state = state.copyWith(error: e.toString(), isLoading: false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 执行登录
|
/// 执行登录
|
||||||
@@ -88,20 +120,23 @@ class LoginViewModel extends _$LoginViewModel {
|
|||||||
state = state.copyWith(isLoading: true, error: null);
|
state = state.copyWith(isLoading: true, error: null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final user = await ref.read(loginUseCaseProvider).execute(
|
final user = await ref
|
||||||
email: email,
|
.read(loginUseCaseProvider)
|
||||||
password: password,
|
.execute(email: email, password: password);
|
||||||
);
|
|
||||||
|
|
||||||
|
if (!ref.mounted) return;
|
||||||
state = state.copyWith(user: user, isLoading: false);
|
state = state.copyWith(user: user, isLoading: false);
|
||||||
} on FormatException catch (e) {
|
} on FormatException catch (e) {
|
||||||
// 格式校验失败(UseCase 层抛出)
|
// 格式校验失败(UseCase 层抛出)
|
||||||
|
if (!ref.mounted) return;
|
||||||
state = state.copyWith(error: e.message, isLoading: false);
|
state = state.copyWith(error: e.message, isLoading: false);
|
||||||
} on ApiError catch (e) {
|
} on ApiError catch (e) {
|
||||||
// 网络 / 服务端错误(Repository → SDK 透传)
|
// 网络 / 服务端错误(Repository → SDK 透传)
|
||||||
|
if (!ref.mounted) return;
|
||||||
state = state.copyWith(error: e.displayMessage, isLoading: false);
|
state = state.copyWith(error: e.displayMessage, isLoading: false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 兜底:防止未预期的异常导致 isLoading 死锁
|
// 兜底:防止未预期的异常导致 isLoading 死锁
|
||||||
|
if (!ref.mounted) return;
|
||||||
state = state.copyWith(error: e.toString(), isLoading: false);
|
state = state.copyWith(error: e.toString(), isLoading: false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ import '../../../domain/repositories/auth_repository.dart';
|
|||||||
/// → AuthRepository.login()
|
/// → AuthRepository.login()
|
||||||
/// → AuthRepositoryImpl.login()
|
/// → AuthRepositoryImpl.login()
|
||||||
/// → _client.executeRequest(LoginRequest)
|
/// → _client.executeRequest(LoginRequest)
|
||||||
/// ← LoginData(DTO)
|
/// ← LoginResponse(SDK 已拆包 envelope)
|
||||||
/// → _onTokenUpdate(token) ← 回调写入 Token(内存 + 持久化,由 Provider 层组合)
|
/// → _onTokenUpdate(accessToken) ← 回调写入 Token(内存 + 持久化,由 Provider 层组合)
|
||||||
/// ← LoginData.toEntity() → User
|
/// ← LoginResponse.toEntity() → User
|
||||||
/// → SocketManager.connect(token) ← 登录后连接 WebSocket
|
/// → SocketManager.connect(token) ← 登录后连接 WebSocket
|
||||||
/// → StorageSdkApi.openDatabase(user.id) ← 按用户 id 打开本地库
|
/// → StorageSdkApi.openDatabase(user.id) ← 按用户 id 打开本地库
|
||||||
/// ← User
|
/// ← User
|
||||||
@@ -41,17 +41,18 @@ class LoginUseCase {
|
|||||||
final ApiConfig _apiConfig;
|
final ApiConfig _apiConfig;
|
||||||
final StorageSdkApi _storageApi;
|
final StorageSdkApi _storageApi;
|
||||||
|
|
||||||
StorageSdkLifecycle get _storageLifeCycle => _storageApi as StorageSdkLifecycle;
|
StorageSdkLifecycle get _storageLifeCycle =>
|
||||||
|
_storageApi as StorageSdkLifecycle;
|
||||||
|
|
||||||
LoginUseCase({
|
LoginUseCase({
|
||||||
required AuthRepository authRepository,
|
required AuthRepository authRepository,
|
||||||
required SocketManager socketManager,
|
required SocketManager socketManager,
|
||||||
required ApiConfig apiConfig,
|
required ApiConfig apiConfig,
|
||||||
required StorageSdkApi storageApi,
|
required StorageSdkApi storageApi,
|
||||||
}) : _authRepository = authRepository,
|
}) : _authRepository = authRepository,
|
||||||
_socketManager = socketManager,
|
_socketManager = socketManager,
|
||||||
_apiConfig = apiConfig,
|
_apiConfig = apiConfig,
|
||||||
_storageApi = storageApi;
|
_storageApi = storageApi;
|
||||||
|
|
||||||
/// 执行登录
|
/// 执行登录
|
||||||
///
|
///
|
||||||
@@ -72,10 +73,7 @@ class LoginUseCase {
|
|||||||
_validatePassword(password);
|
_validatePassword(password);
|
||||||
|
|
||||||
// ── 2. 登录 ──
|
// ── 2. 登录 ──
|
||||||
final user = await _authRepository.login(
|
final user = await _authRepository.login(email: email, password: password);
|
||||||
email: email,
|
|
||||||
password: password,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── 3. 连接 WebSocket ──
|
// ── 3. 连接 WebSocket ──
|
||||||
// token 在 Repository 的 _onTokenUpdate 回调中已写入 ApiConfig,
|
// token 在 Repository 的 _onTokenUpdate 回调中已写入 ApiConfig,
|
||||||
|
|||||||
@@ -15,13 +15,12 @@ class LoginPage extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// ref.watch 保持 loginViewModelProvider 存活(AutoDispose 需要至少一个监听者)
|
||||||
|
final state = ref.watch(loginViewModelProvider);
|
||||||
final s = context.styles;
|
final s = context.styles;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(title: const Text('登录'), automaticallyImplyLeading: false),
|
||||||
title: const Text('登录'),
|
|
||||||
automaticallyImplyLeading: false,
|
|
||||||
),
|
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -34,7 +33,9 @@ class LoginPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => ref.read(loginViewModelProvider.notifier).demoLogin(),
|
onPressed: state.isLoading
|
||||||
|
? null
|
||||||
|
: () => ref.read(loginViewModelProvider.notifier).demoLogin(),
|
||||||
child: const Text('登录'),
|
child: const Text('登录'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -16,32 +16,27 @@ class ThemeView extends ConsumerWidget {
|
|||||||
final current = ref.watch(themeViewModelProvider);
|
final current = ref.watch(themeViewModelProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(title: const Text('主题')),
|
||||||
title: const Text('主题'),
|
|
||||||
),
|
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
const SettingsSectionHeader(title: '外观'),
|
const SettingsSectionHeader(title: '外观'),
|
||||||
ThemeOptionTile(
|
ThemeOptionTile(
|
||||||
label: '跟随系统',
|
label: '跟随系统',
|
||||||
mode: ThemeMode.system,
|
isSelected: current == ThemeMode.system,
|
||||||
current: current,
|
|
||||||
onTap: () => ref
|
onTap: () => ref
|
||||||
.read(themeViewModelProvider.notifier)
|
.read(themeViewModelProvider.notifier)
|
||||||
.setMode(ThemeMode.system),
|
.setMode(ThemeMode.system),
|
||||||
),
|
),
|
||||||
ThemeOptionTile(
|
ThemeOptionTile(
|
||||||
label: '黑色模式',
|
label: '黑色模式',
|
||||||
mode: ThemeMode.dark,
|
isSelected: current == ThemeMode.dark,
|
||||||
current: current,
|
|
||||||
onTap: () => ref
|
onTap: () => ref
|
||||||
.read(themeViewModelProvider.notifier)
|
.read(themeViewModelProvider.notifier)
|
||||||
.setMode(ThemeMode.dark),
|
.setMode(ThemeMode.dark),
|
||||||
),
|
),
|
||||||
ThemeOptionTile(
|
ThemeOptionTile(
|
||||||
label: '白色模式',
|
label: '白色模式',
|
||||||
mode: ThemeMode.light,
|
isSelected: current == ThemeMode.light,
|
||||||
current: current,
|
|
||||||
onTap: () => ref
|
onTap: () => ref
|
||||||
.read(themeViewModelProvider.notifier)
|
.read(themeViewModelProvider.notifier)
|
||||||
.setMode(ThemeMode.light),
|
.setMode(ThemeMode.light),
|
||||||
|
|||||||
@@ -5,14 +5,13 @@ import '../../../../../core/ui/base/context_theme_ext.dart';
|
|||||||
/// 单个主题选项行
|
/// 单个主题选项行
|
||||||
///
|
///
|
||||||
/// 纯展示 + 事件透传,不感知任何 Provider。
|
/// 纯展示 + 事件透传,不感知任何 Provider。
|
||||||
/// 由父级传入 [current] 判断选中状态,[onTap] 处理切换。
|
/// 父级传入 [isSelected] 决定是否显示勾选图标,[onTap] 处理切换。
|
||||||
///
|
///
|
||||||
/// 用法:
|
/// 用法:
|
||||||
/// ```dart
|
/// ```dart
|
||||||
/// ThemeOptionTile(
|
/// ThemeOptionTile(
|
||||||
/// label: '黑色模式',
|
/// label: '黑色模式',
|
||||||
/// mode: ThemeMode.dark,
|
/// isSelected: current == ThemeMode.dark,
|
||||||
/// current: current,
|
|
||||||
/// onTap: () => ref.read(themeViewModelProvider.notifier).setMode(ThemeMode.dark),
|
/// onTap: () => ref.read(themeViewModelProvider.notifier).setMode(ThemeMode.dark),
|
||||||
/// )
|
/// )
|
||||||
/// ```
|
/// ```
|
||||||
@@ -20,20 +19,17 @@ class ThemeOptionTile extends StatelessWidget {
|
|||||||
const ThemeOptionTile({
|
const ThemeOptionTile({
|
||||||
super.key,
|
super.key,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.mode,
|
required this.isSelected,
|
||||||
required this.current,
|
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String label;
|
final String label;
|
||||||
final ThemeMode mode;
|
final bool isSelected;
|
||||||
final ThemeMode current;
|
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final s = context.styles;
|
final s = context.styles;
|
||||||
final isSelected = current == mode;
|
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(label),
|
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.
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
@@ -38,5 +38,8 @@ end
|
|||||||
post_install do |installer|
|
post_install do |installer|
|
||||||
installer.pods_project.targets.each do |target|
|
installer.pods_project.targets.each do |target|
|
||||||
flutter_additional_macos_build_settings(target)
|
flutter_additional_macos_build_settings(target)
|
||||||
|
target.build_configurations.each do |config|
|
||||||
|
config.build_settings['SWIFT_VERSION'] ||= '6.2'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -481,7 +481,7 @@
|
|||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
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";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/im_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/im_app";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
@@ -496,7 +496,7 @@
|
|||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
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";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/im_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/im_app";
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
@@ -511,7 +511,7 @@
|
|||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
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";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/im_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/im_app";
|
||||||
};
|
};
|
||||||
name = Profile;
|
name = Profile;
|
||||||
@@ -557,7 +557,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
@@ -580,7 +580,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 6.2;
|
||||||
};
|
};
|
||||||
name = Profile;
|
name = Profile;
|
||||||
};
|
};
|
||||||
@@ -639,7 +639,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
@@ -689,7 +689,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
@@ -713,7 +713,7 @@
|
|||||||
);
|
);
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 6.2;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@@ -732,7 +732,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 6.2;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,9 +37,13 @@ dependencies:
|
|||||||
# 网络状态监听
|
# 网络状态监听
|
||||||
connectivity_plus: ^6.1.0
|
connectivity_plus: ^6.1.0
|
||||||
|
|
||||||
|
# JWT 解析(token 过期检测、主动刷新)
|
||||||
|
dart_jsonwebtoken: ^3.3.2
|
||||||
|
|
||||||
# 数据库(schema 定义在 im_app,连接/CRUD 封装在 storage_sdk)
|
# 数据库(schema 定义在 im_app,连接/CRUD 封装在 storage_sdk)
|
||||||
drift: ^2.22.0
|
drift: ^2.22.0
|
||||||
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
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
|
import UIKit
|
||||||
|
|
||||||
public class CipherGuardSdkPlugin: NSObject, FlutterPlugin {
|
public class CipherGuardSdkPlugin: NSObject, FlutterPlugin {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ A new Flutter plugin project.
|
|||||||
|
|
||||||
# Flutter.framework does not contain a i386 slice.
|
# Flutter.framework does not contain a i386 slice.
|
||||||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
|
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
|
# If your plugin requires a privacy manifest, for example if it uses any
|
||||||
# required reason APIs, update the PrivacyInfo.xcprivacy file to describe your
|
# required reason APIs, update the PrivacyInfo.xcprivacy file to describe your
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
export 'src/presentation/facade/cipher_guard_sdk_api.dart';
|
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/rsa_key_pair.dart';
|
||||||
export 'src/domain/entities/session_key.dart';
|
export 'src/domain/entities/session_key.dart';
|
||||||
export 'src/domain/entities/encrypted_message.dart';
|
export 'src/domain/entities/encrypted_message.dart';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:isolate';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
@@ -7,45 +8,144 @@ import 'package:crypto/crypto.dart';
|
|||||||
import 'package:encrypt/encrypt.dart' as encrypt_pkg;
|
import 'package:encrypt/encrypt.dart' as encrypt_pkg;
|
||||||
import 'package:pointycastle/api.dart';
|
import 'package:pointycastle/api.dart';
|
||||||
import 'package:pointycastle/asymmetric/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/api.dart';
|
||||||
import 'package:pointycastle/key_generators/rsa_key_generator.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/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 {
|
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 sessionKeySize = 32;
|
||||||
static const int gcmIvLength = 12;
|
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}) {
|
RsaKeyPairResult generateRsaKeyPair({int keySize = 1024}) {
|
||||||
try {
|
try {
|
||||||
// Get secure random
|
|
||||||
final secureRandom = FortunaRandom();
|
final secureRandom = FortunaRandom();
|
||||||
secureRandom.seed(KeyParameter(_generateSecureRandomBytes(32)));
|
secureRandom.seed(KeyParameter(_generateSecureRandomBytes(32)));
|
||||||
|
|
||||||
// Create RSA key generator
|
|
||||||
final keyGen = RSAKeyGenerator();
|
final keyGen = RSAKeyGenerator();
|
||||||
keyGen.init(ParametersWithRandom(
|
keyGen.init(
|
||||||
RSAKeyGeneratorParameters(BigInt.parse('65537'), keySize, 64),
|
ParametersWithRandom(
|
||||||
secureRandom,
|
RSAKeyGeneratorParameters(BigInt.parse('65537'), keySize, 64),
|
||||||
));
|
secureRandom,
|
||||||
|
),
|
||||||
// Generate key pair
|
);
|
||||||
|
|
||||||
final keyPair = keyGen.generateKeyPair();
|
final keyPair = keyGen.generateKeyPair();
|
||||||
final rsaPublicKey = keyPair.publicKey as RSAPublicKey;
|
final rsaPublicKey = keyPair.publicKey;
|
||||||
final rsaPrivateKey = keyPair.privateKey as RSAPrivateKey;
|
final rsaPrivateKey = keyPair.privateKey;
|
||||||
|
|
||||||
// Export to PEM format
|
|
||||||
final publicKeyPem = _encodeRSAPublicKey(rsaPublicKey);
|
final publicKeyPem = _encodeRSAPublicKey(rsaPublicKey);
|
||||||
final privateKeyPem = _encodeRSAPrivateKey(rsaPrivateKey);
|
final privateKeyPem = _encodeRSAPrivateKey(rsaPrivateKey);
|
||||||
|
|
||||||
return RsaKeyPairResult(
|
return RsaKeyPairResult(
|
||||||
publicKey: publicKeyPem,
|
publicKey: publicKeyPem,
|
||||||
privateKey: privateKeyPem,
|
privateKey: privateKeyPem,
|
||||||
@@ -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) {
|
String _encodeRSAPublicKey(RSAPublicKey publicKey) {
|
||||||
// Build RSAPublicKeyInfo structure
|
|
||||||
final topSeq = ASN1Sequence();
|
final topSeq = ASN1Sequence();
|
||||||
|
|
||||||
// AlgorithmIdentifier: OID 1.2.840.113549.1.1.1 + NULL
|
|
||||||
final algoSeq = ASN1Sequence();
|
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());
|
algoSeq.add(ASN1Null());
|
||||||
topSeq.add(algoSeq);
|
topSeq.add(algoSeq);
|
||||||
|
|
||||||
// RSAPublicKey: modulus + publicExponent
|
|
||||||
final keySeq = ASN1Sequence();
|
final keySeq = ASN1Sequence();
|
||||||
keySeq.add(ASN1Integer(publicKey.n!));
|
keySeq.add(ASN1Integer(publicKey.n!));
|
||||||
keySeq.add(ASN1Integer(publicKey.exponent!));
|
keySeq.add(ASN1Integer(publicKey.exponent!));
|
||||||
|
|
||||||
// BitString wrapping the key (with 0 unused bits prefix)
|
|
||||||
final keyBytes = keySeq.encodedBytes;
|
final keyBytes = keySeq.encodedBytes;
|
||||||
final keyList = List<int>.from(keyBytes);
|
final keyList = List<int>.from(keyBytes);
|
||||||
keyList.insert(0, 0); // Add unused bits byte
|
keyList.insert(0, 0);
|
||||||
topSeq.add(ASN1BitString(keyList));
|
topSeq.add(ASN1BitString(keyList));
|
||||||
|
|
||||||
final derBytes = topSeq.encodedBytes;
|
final derBytes = topSeq.encodedBytes;
|
||||||
@@ -82,129 +194,98 @@ class EncryptionFlutterService {
|
|||||||
return '-----BEGIN PUBLIC KEY-----\n$base64\n-----END PUBLIC KEY-----';
|
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) {
|
String _encodeRSAPrivateKey(RSAPrivateKey privateKey) {
|
||||||
// Build RSAPrivateKey structure (PKCS#8 format)
|
|
||||||
final topSeq = ASN1Sequence();
|
final topSeq = ASN1Sequence();
|
||||||
|
|
||||||
// Version (0)
|
|
||||||
topSeq.add(ASN1Integer(BigInt.zero));
|
topSeq.add(ASN1Integer(BigInt.zero));
|
||||||
|
|
||||||
// Modulus
|
|
||||||
topSeq.add(ASN1Integer(privateKey.n!));
|
topSeq.add(ASN1Integer(privateKey.n!));
|
||||||
|
|
||||||
// Public Exponent
|
|
||||||
topSeq.add(ASN1Integer(privateKey.exponent!));
|
topSeq.add(ASN1Integer(privateKey.exponent!));
|
||||||
|
|
||||||
// Private Exponent
|
|
||||||
topSeq.add(ASN1Integer(privateKey.privateExponent!));
|
topSeq.add(ASN1Integer(privateKey.privateExponent!));
|
||||||
|
|
||||||
// Prime P
|
|
||||||
topSeq.add(ASN1Integer(privateKey.p!));
|
topSeq.add(ASN1Integer(privateKey.p!));
|
||||||
|
|
||||||
// Prime Q
|
|
||||||
topSeq.add(ASN1Integer(privateKey.q!));
|
topSeq.add(ASN1Integer(privateKey.q!));
|
||||||
|
|
||||||
// (Optional CRT params omitted for simplicity)
|
|
||||||
|
|
||||||
final derBytes = topSeq.encodedBytes;
|
final derBytes = topSeq.encodedBytes;
|
||||||
final base64 = base64Encode(derBytes.toList());
|
final base64 = base64Encode(derBytes.toList());
|
||||||
return '-----BEGIN PRIVATE KEY-----\n$base64\n-----END PRIVATE KEY-----';
|
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({
|
String encryptPrivateKey({
|
||||||
required String privateKey,
|
required String privateKey,
|
||||||
required String password,
|
required String password,
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
// Generate AES key from MD5(password)
|
|
||||||
final aesKey = _md5Hash(password);
|
final aesKey = _md5Hash(password);
|
||||||
|
|
||||||
// Generate random IV (16 bytes)
|
|
||||||
final iv = _generateSecureRandomBytes(16);
|
final iv = _generateSecureRandomBytes(16);
|
||||||
|
|
||||||
// AES encrypt using encrypt package
|
|
||||||
final secretKey = encrypt_pkg.Key(aesKey);
|
final secretKey = encrypt_pkg.Key(aesKey);
|
||||||
final encryptor = encrypt_pkg.Encrypter(
|
final encryptor = encrypt_pkg.Encrypter(
|
||||||
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.cbc),
|
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.cbc),
|
||||||
);
|
);
|
||||||
|
|
||||||
final encrypted = encryptor.encrypt(privateKey, iv: encrypt_pkg.IV(iv));
|
final encrypted = encryptor.encrypt(privateKey, iv: encrypt_pkg.IV(iv));
|
||||||
final encryptedBytes = encrypted.bytes;
|
final encryptedBytes = encrypted.bytes;
|
||||||
|
|
||||||
// Combine IV + encrypted data
|
|
||||||
final combined = Uint8List(iv.length + encryptedBytes.length);
|
final combined = Uint8List(iv.length + encryptedBytes.length);
|
||||||
combined.setAll(0, iv);
|
combined.setAll(0, iv);
|
||||||
combined.setAll(iv.length, encryptedBytes);
|
combined.setAll(iv.length, encryptedBytes);
|
||||||
|
|
||||||
return base64Encode(combined);
|
return base64Encode(combined);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Failed to encrypt private key: $e');
|
throw Exception('Failed to encrypt private key: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt private key with password (AES-CBC with MD5-derived key)
|
/// 用密码解密私钥(AES-CBC,密码通过 MD5 派生密钥)
|
||||||
String decryptPrivateKey({
|
String decryptPrivateKey({
|
||||||
required String encryptedPrivateKey,
|
required String encryptedPrivateKey,
|
||||||
required String password,
|
required String password,
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
// Generate AES key from MD5(password)
|
|
||||||
final aesKey = _md5Hash(password);
|
final aesKey = _md5Hash(password);
|
||||||
|
|
||||||
// Decode Base64
|
|
||||||
final combined = base64Decode(encryptedPrivateKey);
|
final combined = base64Decode(encryptedPrivateKey);
|
||||||
|
|
||||||
// Extract IV and encrypted data
|
|
||||||
final iv = combined.sublist(0, 16);
|
final iv = combined.sublist(0, 16);
|
||||||
final encBytes = combined.sublist(16);
|
final encBytes = combined.sublist(16);
|
||||||
|
|
||||||
// AES decrypt
|
|
||||||
final secretKey = encrypt_pkg.Key(aesKey);
|
final secretKey = encrypt_pkg.Key(aesKey);
|
||||||
final encryptor = encrypt_pkg.Encrypter(
|
final encryptor = encrypt_pkg.Encrypter(
|
||||||
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.cbc),
|
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.cbc),
|
||||||
);
|
);
|
||||||
|
|
||||||
final decrypted = encryptor.decrypt(
|
final decrypted = encryptor.decrypt(
|
||||||
encrypt_pkg.Encrypted(encBytes),
|
encrypt_pkg.Encrypted(encBytes),
|
||||||
iv: encrypt_pkg.IV(iv),
|
iv: encrypt_pkg.IV(iv),
|
||||||
);
|
);
|
||||||
|
|
||||||
return decrypted;
|
return decrypted;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Failed to decrypt private key: $e');
|
throw Exception('Failed to decrypt private key: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Session Key Management ====================
|
// ==================== 会话密钥管理 ====================
|
||||||
|
|
||||||
/// Generate session key (32 bytes random)
|
/// 生成会话密钥(32 字节随机)
|
||||||
SessionKeyResult generateSessionKey({int initialRound = 1}) {
|
SessionKeyResult generateSessionKey({int initialRound = 1}) {
|
||||||
final keyBytes = _generateSecureRandomBytes(sessionKeySize);
|
final keyBytes = _generateSecureRandomBytes(sessionKeySize);
|
||||||
final key = base64Encode(keyBytes);
|
final key = base64Encode(keyBytes);
|
||||||
|
|
||||||
return SessionKeyResult(
|
return SessionKeyResult(key: key, round: initialRound);
|
||||||
key: key,
|
|
||||||
round: initialRound,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encrypt session key with RSA public key
|
/// 用 RSA 公钥加密会话密钥
|
||||||
String encryptSessionKey({
|
String encryptSessionKey({
|
||||||
required String sessionKey,
|
required String sessionKey,
|
||||||
required String publicKey,
|
required String publicKey,
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
// Parse RSA public key
|
|
||||||
final rsaPublicKey = _parsePublicKey(publicKey);
|
final rsaPublicKey = _parsePublicKey(publicKey);
|
||||||
|
|
||||||
// RSA encrypt using PKCS1 padding (like native implementations)
|
|
||||||
final cipher = PKCS1Encoding(RSAEngine());
|
final cipher = PKCS1Encoding(RSAEngine());
|
||||||
cipher.init(true, PublicKeyParameter<RSAPublicKey>(rsaPublicKey));
|
cipher.init(true, PublicKeyParameter<RSAPublicKey>(rsaPublicKey));
|
||||||
|
|
||||||
final encryptedBytes = cipher.process(utf8.encode(sessionKey));
|
final encryptedBytes = cipher.process(utf8.encode(sessionKey));
|
||||||
return base64Encode(encryptedBytes);
|
return base64Encode(encryptedBytes);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -212,19 +293,17 @@ class EncryptionFlutterService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt session key with RSA private key
|
/// 用 RSA 私钥解密会话密钥
|
||||||
String decryptSessionKey({
|
String decryptSessionKey({
|
||||||
required String encryptedSessionKey,
|
required String encryptedSessionKey,
|
||||||
required String privateKey,
|
required String privateKey,
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
// Parse RSA private key
|
|
||||||
final rsaPrivateKey = _parsePrivateKey(privateKey);
|
final rsaPrivateKey = _parsePrivateKey(privateKey);
|
||||||
|
|
||||||
// RSA decrypt using PKCS1 padding (like native implementations)
|
|
||||||
final cipher = PKCS1Encoding(RSAEngine());
|
final cipher = PKCS1Encoding(RSAEngine());
|
||||||
cipher.init(false, PrivateKeyParameter<RSAPrivateKey>(rsaPrivateKey));
|
cipher.init(false, PrivateKeyParameter<RSAPrivateKey>(rsaPrivateKey));
|
||||||
|
|
||||||
final decryptedBytes = cipher.process(base64Decode(encryptedSessionKey));
|
final decryptedBytes = cipher.process(base64Decode(encryptedSessionKey));
|
||||||
return utf8.decode(decryptedBytes);
|
return utf8.decode(decryptedBytes);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -232,215 +311,288 @@ class EncryptionFlutterService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Message Encryption/Decryption ====================
|
// ==================== 消息加密/解密 ====================
|
||||||
|
|
||||||
/// Encrypt message (AES-CTR with round-based key derivation)
|
/// 加密消息(AES-CTR,使用 round 派生密钥)
|
||||||
EncryptedMessageResult encryptMessage({
|
EncryptedMessageResult encryptMessage({
|
||||||
required String plaintext,
|
required String plaintext,
|
||||||
required String sessionKey,
|
required String sessionKey,
|
||||||
required int round,
|
required int round,
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
// Derive key for round
|
|
||||||
final actualKey = _deriveKeyForRound(sessionKey, round);
|
final actualKey = _deriveKeyForRound(sessionKey, round);
|
||||||
|
|
||||||
// Generate random IV (16 bytes for CTR)
|
|
||||||
final iv = _generateSecureRandomBytes(16);
|
final iv = _generateSecureRandomBytes(16);
|
||||||
|
|
||||||
// AES-CTR encrypt
|
|
||||||
final secretKey = encrypt_pkg.Key(actualKey);
|
final secretKey = encrypt_pkg.Key(actualKey);
|
||||||
final encryptor = encrypt_pkg.Encrypter(
|
final encryptor = encrypt_pkg.Encrypter(
|
||||||
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.ctr),
|
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.ctr),
|
||||||
);
|
);
|
||||||
|
|
||||||
final encrypted = encryptor.encrypt(plaintext, iv: encrypt_pkg.IV(iv));
|
final encrypted = encryptor.encrypt(plaintext, iv: encrypt_pkg.IV(iv));
|
||||||
final encryptedBytes = encrypted.bytes;
|
final encryptedBytes = encrypted.bytes;
|
||||||
|
|
||||||
// Combine IV + encrypted data
|
|
||||||
final combined = Uint8List(iv.length + encryptedBytes.length);
|
final combined = Uint8List(iv.length + encryptedBytes.length);
|
||||||
combined.setAll(0, iv);
|
combined.setAll(0, iv);
|
||||||
combined.setAll(iv.length, encryptedBytes);
|
combined.setAll(iv.length, encryptedBytes);
|
||||||
|
|
||||||
final data = base64Encode(combined);
|
final data = base64Encode(combined);
|
||||||
|
|
||||||
return EncryptedMessageResult(
|
return EncryptedMessageResult(round: round, data: data);
|
||||||
round: round,
|
|
||||||
data: data,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Failed to encrypt message: $e');
|
throw Exception('Failed to encrypt message: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt message (AES-CTR with round-based key derivation)
|
/// 解密消息(AES-CTR,使用 round 派生密钥)
|
||||||
String decryptMessage({
|
String decryptMessage({
|
||||||
required String encryptedData,
|
required String encryptedData,
|
||||||
required String sessionKey,
|
required String sessionKey,
|
||||||
required int round,
|
required int round,
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
// Derive key for round
|
|
||||||
final actualKey = _deriveKeyForRound(sessionKey, round);
|
final actualKey = _deriveKeyForRound(sessionKey, round);
|
||||||
|
|
||||||
// Decode Base64
|
|
||||||
final combined = base64Decode(encryptedData);
|
final combined = base64Decode(encryptedData);
|
||||||
|
|
||||||
// Extract IV and encrypted data
|
|
||||||
final iv = combined.sublist(0, 16);
|
final iv = combined.sublist(0, 16);
|
||||||
final encBytes = combined.sublist(16);
|
final encBytes = combined.sublist(16);
|
||||||
|
|
||||||
// AES-CTR decrypt
|
|
||||||
final secretKey = encrypt_pkg.Key(actualKey);
|
final secretKey = encrypt_pkg.Key(actualKey);
|
||||||
final encryptor = encrypt_pkg.Encrypter(
|
final encryptor = encrypt_pkg.Encrypter(
|
||||||
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.ctr),
|
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.ctr),
|
||||||
);
|
);
|
||||||
|
|
||||||
final decrypted = encryptor.decrypt(
|
final decrypted = encryptor.decrypt(
|
||||||
encrypt_pkg.Encrypted(encBytes),
|
encrypt_pkg.Encrypted(encBytes),
|
||||||
iv: encrypt_pkg.IV(iv),
|
iv: encrypt_pkg.IV(iv),
|
||||||
);
|
);
|
||||||
|
|
||||||
return decrypted;
|
return decrypted;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Failed to decrypt message: $e');
|
throw Exception('Failed to decrypt message: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Push Notification Decryption ====================
|
// ==================== 推送通知解密 ====================
|
||||||
|
|
||||||
/// Set AES secret for push notification decryption
|
/// 设置 AES secret(用于推送通知解密)
|
||||||
void setAesSecret(String aesSecret) {
|
void setAesSecret(String aesSecret) {
|
||||||
_aesSecret = aesSecret;
|
_aesSecret = aesSecret;
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _aesSecret;
|
String? _aesSecret;
|
||||||
|
|
||||||
/// Decrypt push notification (AES-GCM)
|
/// 解密推送通知(AES-GCM)
|
||||||
String decryptPushNotification({
|
String decryptPushNotification({required String encryptedData}) {
|
||||||
required String encryptedData,
|
|
||||||
}) {
|
|
||||||
try {
|
try {
|
||||||
final secret = _aesSecret;
|
final secret = _aesSecret;
|
||||||
if (secret == null) {
|
if (secret == null) {
|
||||||
throw Exception('AES_SECRET not set');
|
throw Exception('AES_SECRET not set');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert hex string to bytes
|
|
||||||
final secretBytes = _hexStringToBytes(secret);
|
final secretBytes = _hexStringToBytes(secret);
|
||||||
|
|
||||||
// Decode Base64
|
|
||||||
final combined = base64Decode(encryptedData);
|
final combined = base64Decode(encryptedData);
|
||||||
|
|
||||||
// Extract IV and encrypted data
|
|
||||||
final iv = combined.sublist(0, gcmIvLength);
|
final iv = combined.sublist(0, gcmIvLength);
|
||||||
final encBytes = combined.sublist(gcmIvLength);
|
final encBytes = combined.sublist(gcmIvLength);
|
||||||
|
|
||||||
// AES-GCM decrypt
|
|
||||||
final secretKey = encrypt_pkg.Key(secretBytes);
|
final secretKey = encrypt_pkg.Key(secretBytes);
|
||||||
final encryptor = encrypt_pkg.Encrypter(
|
final encryptor = encrypt_pkg.Encrypter(
|
||||||
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.gcm),
|
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.gcm),
|
||||||
);
|
);
|
||||||
|
|
||||||
final decrypted = encryptor.decrypt(
|
final decrypted = encryptor.decrypt(
|
||||||
encrypt_pkg.Encrypted(encBytes),
|
encrypt_pkg.Encrypted(encBytes),
|
||||||
iv: encrypt_pkg.IV(iv),
|
iv: encrypt_pkg.IV(iv),
|
||||||
);
|
);
|
||||||
|
|
||||||
return decrypted;
|
return decrypted;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Failed to decrypt push notification: $e');
|
throw Exception('Failed to decrypt push notification: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Helper Methods ====================
|
// ==================== 内部方法 ====================
|
||||||
|
|
||||||
/// Generate secure random bytes
|
/// 生成安全随机字节(复用全局 Random.secure() 实例)
|
||||||
Uint8List _generateSecureRandomBytes(int length) {
|
Uint8List _generateSecureRandomBytes(int length) {
|
||||||
final random = Random.secure();
|
|
||||||
final bytes = Uint8List(length);
|
final bytes = Uint8List(length);
|
||||||
for (var i = 0; i < length; i++) {
|
for (var i = 0; i < length; i++) {
|
||||||
bytes[i] = random.nextInt(256);
|
bytes[i] = _secureRandom.nextInt(256);
|
||||||
}
|
}
|
||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MD5 hash
|
/// MD5 哈希(用于密码派生密钥)
|
||||||
Uint8List _md5Hash(String input) {
|
Uint8List _md5Hash(String input) {
|
||||||
final bytes = utf8.encode(input);
|
final bytes = utf8.encode(input);
|
||||||
final hash = md5.convert(bytes).bytes as Uint8List;
|
final hash = md5.convert(bytes).bytes;
|
||||||
return hash;
|
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) {
|
Uint8List _deriveKeyForRound(String sessionKey, int targetRound) {
|
||||||
// Base64 decode session key
|
final modeName = kdfMode == KdfMode.md5 ? 'md5' : 'pbkdf2';
|
||||||
final keyBytes = base64Decode(sessionKey);
|
final cacheKey = '$sessionKey:$targetRound:$modeName';
|
||||||
|
|
||||||
// Apply MD5 for the round (simplified version)
|
// 缓存命中 — 移至末尾以维护 LRU 顺序
|
||||||
final hash = md5.convert(keyBytes).bytes as Uint8List;
|
final cached = _derivedKeyCache.remove(cacheKey);
|
||||||
|
if (cached != null) {
|
||||||
return hash;
|
_derivedKeyCache[cacheKey] = cached;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算派生密钥
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse RSA public key from PEM
|
/// 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) {
|
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('-----BEGIN PUBLIC KEY-----', '')
|
||||||
.replaceAll('-----END PUBLIC KEY-----', '')
|
.replaceAll('-----END PUBLIC KEY-----', '')
|
||||||
.replaceAll('\n', '')
|
.replaceAll('\n', '')
|
||||||
.trim();
|
.trim();
|
||||||
final bytes = base64Decode(base64);
|
final bytes = base64Decode(b64);
|
||||||
|
|
||||||
// Parse ASN.1 DER format
|
|
||||||
final asn1Parser = ASN1Parser(Uint8List.fromList(bytes));
|
final asn1Parser = ASN1Parser(Uint8List.fromList(bytes));
|
||||||
final topLevelSeq = asn1Parser.nextObject() as ASN1Sequence;
|
final topLevelSeq = asn1Parser.nextObject() as ASN1Sequence;
|
||||||
|
|
||||||
final subjectPublicKeyInfo = topLevelSeq.elements[1] as ASN1BitString;
|
final subjectPublicKeyInfo = topLevelSeq.elements[1] as ASN1BitString;
|
||||||
final keyBytes = subjectPublicKeyInfo.contentBytes();
|
final keyBytes = subjectPublicKeyInfo.contentBytes();
|
||||||
final keyParser = ASN1Parser(Uint8List.fromList(keyBytes));
|
final keyParser = ASN1Parser(Uint8List.fromList(keyBytes));
|
||||||
final keySeq = keyParser.nextObject() as ASN1Sequence;
|
final keySeq = keyParser.nextObject() as ASN1Sequence;
|
||||||
|
|
||||||
final modulus = keySeq.elements[0] as ASN1Integer;
|
final modulus = keySeq.elements[0] as ASN1Integer;
|
||||||
final publicExponent = keySeq.elements[1] as ASN1Integer;
|
final publicExponent = keySeq.elements[1] as ASN1Integer;
|
||||||
|
|
||||||
return RSAPublicKey(
|
final key = RSAPublicKey(
|
||||||
modulus.valueAsBigInteger,
|
modulus.valueAsBigInteger,
|
||||||
publicExponent.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) {
|
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('-----BEGIN PRIVATE KEY-----', '')
|
||||||
.replaceAll('-----END PRIVATE KEY-----', '')
|
.replaceAll('-----END PRIVATE KEY-----', '')
|
||||||
.replaceAll('\n', '')
|
.replaceAll('\n', '')
|
||||||
.trim();
|
.trim();
|
||||||
final bytes = base64Decode(base64);
|
final bytes = base64Decode(b64);
|
||||||
|
|
||||||
// Parse ASN.1 DER format
|
|
||||||
final asn1Parser = ASN1Parser(Uint8List.fromList(bytes));
|
final asn1Parser = ASN1Parser(Uint8List.fromList(bytes));
|
||||||
final keySeq = asn1Parser.nextObject() as ASN1Sequence;
|
final keySeq = asn1Parser.nextObject() as ASN1Sequence;
|
||||||
|
|
||||||
final modulus = keySeq.elements[1] as ASN1Integer;
|
final modulus = keySeq.elements[1] as ASN1Integer;
|
||||||
final privateExponent = keySeq.elements[3] as ASN1Integer;
|
final privateExponent = keySeq.elements[3] as ASN1Integer;
|
||||||
final p = keySeq.elements[4] as ASN1Integer;
|
final p = keySeq.elements[4] as ASN1Integer;
|
||||||
final q = keySeq.elements[5] as ASN1Integer;
|
final q = keySeq.elements[5] as ASN1Integer;
|
||||||
|
|
||||||
return RSAPrivateKey(
|
final key = RSAPrivateKey(
|
||||||
modulus.valueAsBigInteger,
|
modulus.valueAsBigInteger,
|
||||||
privateExponent.valueAsBigInteger,
|
privateExponent.valueAsBigInteger,
|
||||||
p.valueAsBigInteger,
|
p.valueAsBigInteger,
|
||||||
q.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) {
|
Uint8List _hexStringToBytes(String hex) {
|
||||||
final len = hex.length;
|
final len = hex.length;
|
||||||
final data = Uint8List(len ~/ 2);
|
final data = Uint8List(len ~/ 2);
|
||||||
for (var i = 0; i < len; i += 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;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -468,4 +620,3 @@ class EncryptedMessageResult {
|
|||||||
|
|
||||||
EncryptedMessageResult({required this.round, required this.data});
|
EncryptedMessageResult({required this.round, required this.data});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ class EncryptionRepositoryImpl implements EncryptionRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<RsaKeyPair> generateRsaKeyPair({int keySize = 1024}) async {
|
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(
|
return RsaKeyPair(
|
||||||
publicKey: result.publicKey,
|
publicKey: result.publicKey,
|
||||||
privateKey: result.privateKey,
|
privateKey: result.privateKey,
|
||||||
@@ -50,10 +51,7 @@ class EncryptionRepositoryImpl implements EncryptionRepository {
|
|||||||
@override
|
@override
|
||||||
Future<SessionKey> generateSessionKey({int initialRound = 1}) async {
|
Future<SessionKey> generateSessionKey({int initialRound = 1}) async {
|
||||||
final result = _service.generateSessionKey(initialRound: initialRound);
|
final result = _service.generateSessionKey(initialRound: initialRound);
|
||||||
return SessionKey(
|
return SessionKey(key: result.key, round: result.round);
|
||||||
key: result.key,
|
|
||||||
round: result.round,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -91,10 +89,7 @@ class EncryptionRepositoryImpl implements EncryptionRepository {
|
|||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
round: round,
|
round: round,
|
||||||
);
|
);
|
||||||
return EncryptedMessage(
|
return EncryptedMessage(round: result.round, data: result.data);
|
||||||
round: result.round,
|
|
||||||
data: result.data,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -110,6 +105,11 @@ class EncryptionRepositoryImpl implements EncryptionRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 缓存管理 ====================
|
||||||
|
|
||||||
|
@override
|
||||||
|
void clearDerivedKeyCache() => _service.clearDerivedKeyCache();
|
||||||
|
|
||||||
// ==================== 原生平台同步 ====================
|
// ==================== 原生平台同步 ====================
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -147,4 +147,3 @@ class EncryptionRepositoryImpl implements EncryptionRepository {
|
|||||||
return _service.decryptPushNotification(encryptedData: encryptedData);
|
return _service.decryptPushNotification(encryptedData: encryptedData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import '../entities/encrypted_message.dart';
|
|||||||
|
|
||||||
abstract class EncryptionRepository {
|
abstract class EncryptionRepository {
|
||||||
// ==================== RSA 金鑰管理 ====================
|
// ==================== RSA 金鑰管理 ====================
|
||||||
|
|
||||||
/// 生成 RSA 金鑰對
|
/// 生成 RSA 金鑰對
|
||||||
/// [keySize] 金鑰長度 (預設 1024, 可用 2048)
|
/// [keySize] 金鑰長度 (預設 1024, 可用 2048)
|
||||||
Future<RsaKeyPair> generateRsaKeyPair({int keySize = 1024});
|
Future<RsaKeyPair> generateRsaKeyPair({int keySize = 1024});
|
||||||
@@ -84,6 +84,14 @@ abstract class EncryptionRepository {
|
|||||||
required Map<String, Map<String, dynamic>> chatMap,
|
required Map<String, Map<String, dynamic>> chatMap,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== 缓存管理 ====================
|
||||||
|
|
||||||
|
/// 清空派生密钥缓存
|
||||||
|
///
|
||||||
|
/// 在 session key 轮换时调用,确保旧密钥的派生结果不会被复用。
|
||||||
|
/// 不影响已加密的消息,只影响后续加解密操作的密钥派生。
|
||||||
|
void clearDerivedKeyCache();
|
||||||
|
|
||||||
// ==================== 配置相關 ====================
|
// ==================== 配置相關 ====================
|
||||||
|
|
||||||
/// 設置 AES_SECRET (用於推送解密)
|
/// 設置 AES_SECRET (用於推送解密)
|
||||||
@@ -91,8 +99,5 @@ abstract class EncryptionRepository {
|
|||||||
|
|
||||||
/// 解密 APNS 推送通知內容
|
/// 解密 APNS 推送通知內容
|
||||||
/// 使用 release.json 中的 AES_SECRET
|
/// 使用 release.json 中的 AES_SECRET
|
||||||
Future<String?> decryptPushNotification({
|
Future<String?> decryptPushNotification({required String encryptedData});
|
||||||
required String encryptedData,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,59 +7,124 @@ 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/domain/entities/encrypted_message.dart';
|
||||||
import 'package:cipher_guard_sdk/src/presentation/wiring/cipher_guard_sdk_wiring.dart';
|
import 'package:cipher_guard_sdk/src/presentation/wiring/cipher_guard_sdk_wiring.dart';
|
||||||
|
|
||||||
abstract class CipherGuardSdkApi
|
abstract class CipherGuardSdkApi {
|
||||||
{
|
|
||||||
factory CipherGuardSdkApi() => CipherGuardSdkWiring.build();
|
factory CipherGuardSdkApi() => CipherGuardSdkWiring.build();
|
||||||
|
|
||||||
// ==================== 平台版本 ====================
|
// ==================== 平台版本 ====================
|
||||||
|
|
||||||
/// 獲取平台版本
|
/// 獲取平台版本
|
||||||
Future<String?> platformVersion();
|
Future<String?> platformVersion();
|
||||||
|
|
||||||
// ==================== RSA 金鑰管理 ====================
|
// ==================== RSA 金鑰管理 ====================
|
||||||
|
|
||||||
/// 生成 RSA 金鑰對
|
/// 生成 RSA 金鑰對
|
||||||
Future<RsaKeyPair> generateRsaKeyPair({int keySize = 1024});
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
// ==================== 會話金鑰管理 ====================
|
// ==================== 會話金鑰管理 ====================
|
||||||
|
|
||||||
/// 生成 AES 會話金鑰
|
/// 生成 AES 會話金鑰
|
||||||
Future<SessionKey> generateSessionKey({int initialRound = 1});
|
Future<SessionKey> generateSessionKey({int initialRound = 1});
|
||||||
|
|
||||||
/// 用 RSA 公鑰加密會話金鑰
|
/// 用 RSA 公鑰加密會話金鑰
|
||||||
Future<String> encryptSessionKey({required String sessionKey, required String publicKey,});
|
Future<String> encryptSessionKey({
|
||||||
|
required String sessionKey,
|
||||||
|
required String publicKey,
|
||||||
|
});
|
||||||
|
|
||||||
/// 用 RSA 私鑰解密會話金鑰
|
/// 用 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)
|
/// 同步加密金鑰到原生平台 (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,
|
||||||
|
});
|
||||||
|
|
||||||
// ==================== 推送通知解密 ====================
|
// ==================== 推送通知解密 ====================
|
||||||
|
|
||||||
/// 設置 AES_SECRET (用於推送解密)
|
/// 設置 AES_SECRET (用於推送解密)
|
||||||
Future<void> setAesSecret({required String aesSecret});
|
Future<void> setAesSecret({required String aesSecret});
|
||||||
|
|
||||||
/// 解密 APNS 推送通知內容
|
/// 解密 APNS 推送通知內容
|
||||||
Future<String?> decryptPushNotification({required String encryptedData,});
|
Future<String?> decryptPushNotification({required String encryptedData});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user