diff --git a/Doc/IM_App_架构设计.html b/Doc/IM_App_架构设计.html index bfedfb8..8cb14bf 100644 --- a/Doc/IM_App_架构设计.html +++ b/Doc/IM_App_架构设计.html @@ -883,13 +883,16 @@ flowchart TD │ ├── data/ │ │ ├── datasources/ │ │ │ ├── http/ -│ │ │ │ ├── api_client.dart # Dio REST 客户端 +│ │ │ │ ├── api_client.dart # Dio REST 客户端 +│ │ │ │ ├── token_refresh_manager.dart # Token 刷新管理(竞态安全 + 超时 + 时间窗口复用) │ │ │ │ └── interceptor/ -│ │ │ │ ├── auth_interceptor.dart # Token + 默认 header 注入 -│ │ │ │ ├── retry_interceptor.dart # Token 刷新 + 瞬态错误重试 -│ │ │ │ └── logging_interceptor.dart # 请求/响应日志 -│ │ │ └── socket/ -│ │ │ └── socket_client.dart # WebSocket 长连接(心跳/重连/Stream) +│ │ │ │ ├── auth_interceptor.dart # Token + 默认 header 注入 +│ │ │ │ ├── encryption_interceptor.dart # 加密拦截器(预留给 cipher_guard_sdk) +│ │ │ │ ├── retry_interceptor.dart # Token 刷新 + 瞬态错误重试 + 业务错误钩子 +│ │ │ │ └── logging_interceptor.dart # 请求/响应日志 +│ │ │ ├── socket/ +│ │ │ │ └── socket_client.dart # WebSocket 长连接(心跳/重连/Stream/加密钩子) +│ │ │ └── networks_sdk_method_channel_datasource.dart # 统一执行入口 │ │ ├── dto/ │ │ │ ├── api_requestable.dart # 请求基类 + fromJson 注册表 │ │ │ └── api_response_wrapper.dart # { code, message/msg, data } 信封解析 @@ -898,11 +901,12 @@ flowchart TD │ │ └── networks_messaging_repository_impl.dart │ ├── domain/ │ │ ├── entities/ -│ │ │ ├── api_error.dart # @freezed HTTP 错误联合类型 +│ │ │ ├── api_error.dart # @freezed HTTP 错误联合类型(7 变体) +│ │ │ ├── encrypted_request.dart # 加密请求结果数据类 │ │ │ ├── socket_error.dart # @freezed WebSocket 错误联合类型 │ │ │ ├── socket_connection_state.dart # 连接状态 enum │ │ │ ├── http_method.dart # GET/POST/PUT/DELETE/PATCH -│ │ │ └── api_request_type.dart # request/login/upload +│ │ │ └── api_request_type.dart # request/login/upload/stream/download │ │ └── repositories/ │ │ ├── networks_sdk_repository.dart │ │ └── networks_messaging_repository.dart @@ -911,9 +915,9 @@ flowchart TD │ │ ├── networks_sdk_api.dart # HTTP 公开 API 接口 │ │ └── networks_messaging_api.dart # WebSocket 公开 API 接口 │ └── wiring/ -│ ├── api_config.dart # HTTP 配置(baseURL/Token/回调) -│ ├── socket_config.dart # WebSocket 配置(心跳/重连策略) -│ ├── network_callbacks.dart # 回调类型定义 +│ ├── api_config.dart # HTTP 配置(baseURL/Token/回调/加密/重试) +│ ├── socket_config.dart # WebSocket 配置(心跳/重连/加密钩子) +│ ├── network_callbacks.dart # 回调类型定义(认证/加密/业务错误/下载/WS) │ ├── networks_sdk_core.dart │ ├── networks_sdk_api_impl.dart │ ├── networks_messaging_api_impl.dart @@ -1670,7 +1674,7 @@ final viewModel = ref.read(chatViewModelProvider.notifier); // viewModel 类型
// Riverpod DevTools 可以看到完整的依赖图
ChatViewModel
├─ chatRepositoryProvider
- │ ├─ apiClientProvider
+ │ ├─ networkSdkApiProvider
│ └─ messageLocalDataSourceProvider
└─ sendMessageUseCaseProvider
└─ chatRepositoryProvider
@@ -2187,8 +2191,8 @@ extension APIRequestableDefaults<T> on APIRequestable<T> {
/// 执行 API 请求 - 唯一的请求入口
Future<T?> executeRequest<T>(Ref ref, APIRequestable<T> request) async {
- final dio = ref.read(apiClientProvider);
- final config = ref.read(aPIConfigurationProvider);
+ final client = ref.read(networkSdkApiProvider);
+ final config = ref.read(apiConfigProvider);
// 1. 检查网络连接
if (!networkManager.isNetworkAvailable) {
@@ -2646,18 +2650,20 @@ final apiConfigProvider = Provider<ApiConfig>((ref) {
);
});
-/// 2. API 客户端(内部自动挂载 Auth / Retry / Logging 拦截器)
-final apiClientProvider = Provider<ApiClient>((ref) {
- return ApiClient(config: ref.read(apiConfigProvider));
+/// 2. Networks SDK API Provider(全局单例,Facade 接口)
+/// 内部自动挂载 AuthInterceptor / EncryptionInterceptor / RetryInterceptor / LoggingInterceptor
+final networkSdkApiProvider = Provider<NetworksSdkApi>((ref) {
+ final config = ref.read(apiConfigProvider);
+ return NetworksSdkWiring.build(config: config);
});
// ── features/auth/di/auth_providers.dart ── (Auth 模块完整 DI 链路)
-/// 3. Repository(注入 domain 接口类型,ViewModel 不感知具体实现)
+/// 3. Repository(注入 Facade 接口类型,ViewModel 不感知具体实现)
final authRepositoryProvider = Provider<AuthRepository>((ref) {
final apiConfig = ref.read(apiConfigProvider);
return AuthRepositoryImpl(
- client: ref.read(apiClientProvider), // 直接注入 ApiClient
+ client: ref.read(networkSdkApiProvider), // 注入 Facade 接口
onTokenUpdate: (token) {
apiConfig.updateToken(token); // 内存(networks_sdk)
// secureStorage.saveToken(token); // 持久化(storage_sdk,待接入)
@@ -2707,8 +2713,9 @@ class LoginViewModel extends _$LoginViewModel {
→ Repository: _client.executeRequest(LoginRequest(...))
→ ApiClient.executeRequest() ← networks_sdk 内部
→ AuthInterceptor ← 注入 token + headers
+ → EncryptionInterceptor ← 加密请求体(预留)
→ Dio.request(baseURL + path, data) ← 实际 HTTP 请求
- → RetryInterceptor ← token 过期自动刷新重试
+ → RetryInterceptor ← token 过期自动刷新重试 + 业务错误钩子
→ LoggingInterceptor ← 请求/响应日志
← request.decodeResponse(response) ← 自动解码
← ApiResponseWrapper.fromJson ← 拆 { code, msg, data }
@@ -2987,8 +2994,9 @@ flowchart TD
│ │ └── auth_guard.dart # 登录守卫(switch AppRouteName,穷举防漏路由)
│ │
│ └── di/ # 全局 DI — 手动装配的 Provider
-│ ├── network_provider.dart # NetworkMonitor + ApiConfig + ApiClient + SocketConfig + SocketClient + SocketManager
-│ └── app_providers.dart # 全局共享状态(themeModeProvider + AuthNotifier)
+│ ├── network_provider.dart # NetworkMonitor + ApiConfig + NetworksSdkApi + SocketConfig + SocketClient + SocketManager
+│ ├── db_provider.dart # StorageSdkApi(注入 AppDatabase factory)
+│ └── app_providers.dart # AppInitializer + ThemeModeNotifier + AuthNotifier
│
├── features/ # 功能模块(垂直切片):Feature 间禁止直接 import
│ │
@@ -3022,6 +3030,7 @@ flowchart TD
│ ├── di/
│ │ └── settings_providers.dart # settingsRepositoryProvider(待 storage_sdk 接入)
│ ├── presentation/
+│ │ ├── settings_view_model.dart # @riverpod ViewModel(设置页导航)
│ │ └── theme_view_model.dart # @riverpod ViewModel(生成 theme_view_model.g.dart)
│ ├── usecases/
│ │ └── set_theme_usecase.dart # 主题切换用例
@@ -3081,6 +3090,8 @@ flowchart TD
│
└── ui/ # Core UI(设计系统 + 可复用组件)
├── base/ # 设计 Token
+ │ ├── assets.dart # 静态资源路径常量(AppAssets:logo / 占位图)
+ │ ├── icons.dart # 图标常量(AppIcons:导航 / 操作 / 聊天 / 用户 / 状态)
│ ├── app_theme.dart # ThemeData 组装(Light / Dark)
│ ├── colors.dart # 颜色体系(品牌色 / 语义色 / 灰阶)
│ ├── context_theme_ext.dart # BuildContext 主题扩展(context.theme / context.colors)
@@ -3172,7 +3183,7 @@ flowchart LR
direction TB
Step1["① 用户点击发送按钮"]
Step2["② ref.read(chatVM.notifier)
.sendMessage(content)"]
- Step3["③ ViewModel 调用 UseCase
→ Repository → ApiClient"]
+ Step3["③ ViewModel 调用 UseCase
→ Repository → NetworksSdkApi"]
Step4["④ state = state.copyWith(
messages: [..., newMsg])"]
Step5["⑤ ref.watch(chatVM) 检测变化
→ ConsumerWidget 自动 rebuild"]
Step6["⑥ UI 展示最新消息列表"]
@@ -3356,7 +3367,7 @@ abstract class ChatRepository {
// Data 层实现接口
class ChatRepositoryImpl implements ChatRepository {
- final ApiClient _client;
+ final NetworksSdkApi _client;
final MessageLocalDataSource _localDataSource;
@override
@@ -5623,7 +5634,7 @@ flowchart TD
│ ├── file_storage.dart # 文件存储管理
│ └── image_cache.dart # 图片缓存
│
-├── remote/ # Request 文件(一个端点一个文件,Repository 直接调 ApiClient)
+├── remote/ # Request 文件(一个端点一个文件,Repository 直接调 NetworksSdkApi)
│ ├── login_request.dart # 登录端点
│ ├── logout_request.dart # 登出端点
│ ├── send_message_request.dart # 发消息端点
@@ -5648,17 +5659,17 @@ flowchart TD
Domain[Domain Layer
domain/repositories/
Repository 接口] -.实现.-> Repo[Data Layer
data/repositories/
Repository 实现]
Repo -->|读取| LocalDS[Local DataSource
data/local/]
- Repo -->|请求| ApiClient[ApiClient
networks_sdk]
+ Repo -->|请求| SdkApi[NetworksSdkApi
networks_sdk]
Repo -->|缓存| Cache[Cache Manager
data/cache/]
LocalDS -->|Drift| DB[(Database)]
- ApiClient -->|HTTP/WebSocket| API[API Server]
+ SdkApi -->|HTTP/WebSocket| API[API Server]
Cache -->|内存| Memory[Memory Cache]
style Domain fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
style Repo fill:#e8f5e9,stroke:#388e3c,stroke-width:2px
style LocalDS fill:#c8e6c9,stroke:#388e3c
- style ApiClient fill:#c8e6c9,stroke:#388e3c
+ style SdkApi fill:#c8e6c9,stroke:#388e3c
style Cache fill:#c8e6c9,stroke:#388e3c
@@ -5686,14 +5697,14 @@ class MessageLocalDataSource {
- 一个端点 = 一个文件:Response DTO + Request 类放在同一文件中
-- Repository 直接调 ApiClient:无需 RemoteDataSource 中间层
+- Repository 直接调 NetworksSdkApi:无需 RemoteDataSource 中间层
- @ApiRequest 注解 + 代码生成:自动实现 path / method / fromJson 注册
// 示例:Repository 直接调用 Request
// data/repositories/message_repository_impl.dart
class MessageRepositoryImpl implements MessageRepository {
- final ApiClient _client;
+ final NetworksSdkApi _client;
Future<SendMessageData?> sendMessage({
required String chatId,
@@ -5758,9 +5769,9 @@ flowchart LR
RepoImpl -->|1. 检查缓存| Cache[Cache]
RepoImpl -->|2. 读取本地| LocalDS[Local DS]
- RepoImpl -->|3. 请求远程| ApiClient2[ApiClient]
+ RepoImpl -->|3. 请求远程| SdkApi2[NetworksSdkApi]
- ApiClient2 -->|DTO| RepoImpl
+ SdkApi2 -->|DTO| RepoImpl
RepoImpl -->|转换| Entity[Entity]
Entity -->|返回| UC
@@ -5884,13 +5895,16 @@ flowchart LR
├── data/
│ ├── datasources/
│ │ ├── http/
- │ │ │ ├── api_client.dart # Dio REST 客户端(executeRequest<T> 唯一入口)
+ │ │ │ ├── api_client.dart # Dio REST 客户端
+ │ │ │ ├── token_refresh_manager.dart # Token 刷新管理(竞态安全 + 超时 + 时间窗口复用 + 主动刷新)
│ │ │ └── interceptor/
- │ │ │ ├── auth_interceptor.dart # Token + 默认 header 注入
- │ │ │ ├── retry_interceptor.dart # Token 刷新 + 瞬态错误重试
- │ │ │ └── logging_interceptor.dart # 请求/响应日志
- │ │ └── socket/
- │ │ └── socket_client.dart # WebSocket 长连接(心跳/重连/Stream 输出)
+ │ │ │ ├── auth_interceptor.dart # Token + 默认 header 注入
+ │ │ │ ├── encryption_interceptor.dart # 请求加密 / 响应解密(预留给 cipher_guard_sdk)
+ │ │ │ ├── retry_interceptor.dart # Token 刷新 + 瞬态错误重试 + 业务错误钩子
+ │ │ │ └── logging_interceptor.dart # 请求/响应日志
+ │ │ ├── socket/
+ │ │ │ └── socket_client.dart # WebSocket 长连接(心跳/重连/Stream/Token 热更新/加密钩子)
+ │ │ └── networks_sdk_method_channel_datasource.dart # 统一执行入口(executeRequest / executeDownload)
│ ├── dto/
│ │ ├── api_requestable.dart # 请求基类 + fromJson 注册表 + 解码扩展
│ │ └── api_response_wrapper.dart # { code, message/msg, data } 信封解析
@@ -5899,22 +5913,23 @@ flowchart LR
│ └── networks_messaging_repository_impl.dart
├── domain/
│ ├── entities/
- │ │ ├── api_error.dart # @freezed HTTP 错误联合类型
+ │ │ ├── api_error.dart # @freezed HTTP 错误联合类型(7 变体,含 cancelled)
+ │ │ ├── encrypted_request.dart # 加密请求结果数据类(path / headers / body 覆盖)
│ │ ├── socket_error.dart # @freezed WebSocket 错误联合类型
│ │ ├── socket_connection_state.dart # 连接状态 enum
│ │ ├── http_method.dart # GET / POST / PUT / DELETE / PATCH
- │ │ └── api_request_type.dart # request / login / upload
+ │ │ └── api_request_type.dart # request / login / upload / stream / download
│ └── repositories/
│ ├── networks_sdk_repository.dart
│ └── networks_messaging_repository.dart
└── presentation/
├── facade/
- │ ├── networks_sdk_api.dart # HTTP 公开 API 接口
- │ └── networks_messaging_api.dart # WebSocket 公开 API 接口
+ │ ├── networks_sdk_api.dart # HTTP 公开 API 接口(含 executeDownload)
+ │ └── networks_messaging_api.dart # WebSocket 公开 API 接口(含 updateToken / sendBytes)
└── wiring/
- ├── api_config.dart # HTTP 配置(baseURL / token / 回调)
- ├── socket_config.dart # WebSocket 配置(心跳 / 重连策略)
- ├── network_callbacks.dart # 回调类型定义(OnTokenRefresh 等)
+ ├── api_config.dart # HTTP 配置(baseURL / token / 回调 / 加密 / 重试 / 主动刷新)
+ ├── socket_config.dart # WebSocket 配置(心跳 / 重连 / 加密钩子 / 压缩)
+ ├── network_callbacks.dart # 回调类型定义(认证 / 加密 / 业务错误 / 下载进度 / WS 加密)
├── networks_sdk_core.dart
├── networks_sdk_api_impl.dart
├── networks_messaging_api_impl.dart
@@ -5928,7 +5943,7 @@ flowchart LR
职责 SDK (networks_sdk) App 层 (im_app)
-Dio 管理 ApiClient 内部创建管理 构造 ApiClient 传入 config
+Dio 管理 ApiClient 内部创建管理 通过 NetworksSdkWiring.build(config:) 创建
baseURL ApiConfig.baseURL AppConfig.apiBaseUrl 提供初始值
Token 存储 ApiConfig.token(内存) 安全存储、持久化
Token 刷新 检测过期 → 调 onTokenRefresh 提供回调实现
@@ -5941,7 +5956,7 @@ flowchart LR
WebSocket 重连 指数退避自动重连(1s→2s→4s→8s→16s→30s) 无需关心
WebSocket 生命周期 提供 onEnterForeground/Background App 层调用(AppLifecycleListener)
WebSocket 消息解析 JSON.decode → Stream 输出 App 层按 type 过滤 + DTO 解析
-Riverpod 无依赖 Provider 包装 ApiClient / SocketClient
+Riverpod 无依赖 Provider 包装 NetworksSdkApi / SocketClient
@@ -5968,7 +5983,7 @@ flowchart LR
一个端点 = 一个 Request 文件:Response DTO + Request 类放在同一文件中
Response DTO 必须有 toEntity():统一 DTO → Domain Entity 的转换入口
持久化 DTO 和 Response DTO 分开:Response DTO(XxxData)在 request 文件中,持久化 DTO(XxxDto)在 data/models/
-禁止跳层:ViewModel → Repository(→ UseCase 按需)→ ApiClient,每层职责明确
+禁止跳层:ViewModel → Repository(→ UseCase 按需)→ NetworksSdkApi,每层职责明确
傻瓜式教程:从零开始定义并发送一个接口
@@ -6112,10 +6127,10 @@ class LoginRequest extends ApiRequestable<LoginData> // ← 固定写法
-第 2 步:在 Repository 中调用 ApiClient,转为 Domain Entity
+第 2 步:在 Repository 中调用 NetworksSdkApi,转为 Domain Entity
在哪写:lib/data/repositories/auth_repository_impl.dart
-做什么:直接调 ApiClient.executeRequest → 拿到 DTO → 回调写 Token → 转为 Domain Entity → 返回。
+做什么:调 NetworksSdkApi.executeRequest → 拿到 DTO → 回调写 Token → 转为 Domain Entity → 返回。
import 'package:networks_sdk/networks_sdk.dart';
import '../../domain/entities/user.dart';
@@ -6123,11 +6138,11 @@ import '../../domain/repositories/auth_repository.dart';
import '../remote/login_request.dart';
class AuthRepositoryImpl implements AuthRepository {
- final ApiClient _client; // ← 直接注入 ApiClient
+ final NetworksSdkApi _client; // ← 注入 Facade 接口
final void Function(String?) _onTokenUpdate; // ← 回调,由 Provider 层组合
AuthRepositoryImpl({
- required ApiClient client,
+ required NetworksSdkApi client,
required void Function(String?) onTokenUpdate,
}) : _client = client,
_onTokenUpdate = onTokenUpdate;
@@ -6137,7 +6152,7 @@ class AuthRepositoryImpl implements AuthRepository {
required String email,
required String password,
}) async {
- // 1. 直接调 ApiClient,构造请求 → 发 HTTP → 自动解码 → 返回 DTO
+ // 1. 调 NetworksSdkApi,构造请求 → 发 HTTP → 自动解码 → 返回 DTO
final LoginData? loginData = await _client.executeRequest(
LoginRequest(email: email, password: password),
);
@@ -6162,15 +6177,15 @@ class AuthRepositoryImpl implements AuthRepository {
3.1 注册 Provider(DI 装配)
在哪写:lib/features/{模块}/di/{模块}_providers.dart
-做什么:在 Feature 目录下创建 Provider 文件,注册该模块的 DI 链路(Repository → UseCase 按需)。app/di/ 只提供 SDK 基础设施(ApiConfig / ApiClient),业务模块的 Provider 内聚在 Feature 目录下。
+做什么:在 Feature 目录下创建 Provider 文件,注册该模块的 DI 链路(Repository → UseCase 按需)。app/di/ 只提供 SDK 基础设施(ApiConfig / NetworksSdkApi),业务模块的 Provider 内聚在 Feature 目录下。
// ── features/auth/di/auth_providers.dart ──
-// Repository(直接注入 ApiClient + 回调组合多个 SDK 能力)
+// Repository(注入 Facade 接口 + 回调组合多个 SDK 能力)
final authRepositoryProvider = Provider<AuthRepository>((ref) {
final apiConfig = ref.read(apiConfigProvider);
return AuthRepositoryImpl(
- client: ref.read(apiClientProvider), // 直接注入 ApiClient
+ client: ref.read(networkSdkApiProvider), // 注入 Facade 接口
onTokenUpdate: (token) {
apiConfig.updateToken(token); // 内存(networks_sdk)
// secureStorage.saveToken(token); // 持久化(storage_sdk,待接入)
@@ -6199,7 +6214,7 @@ import '../../../domain/repositories/message_repository.dart';
// ── Repository ──
final messageRepositoryProvider = Provider<MessageRepository>((ref) {
return MessageRepositoryImpl(
- client: ref.read(apiClientProvider), // 直接注入 ApiClient
+ client: ref.read(networkSdkApiProvider), // 注入 Facade 接口
);
});
@@ -6207,7 +6222,7 @@ final messageRepositoryProvider = Provider<MessageRepository>((ref) {
// 如需 UseCase(多步编排、跨模块协调),参考 auth_providers.dart 中的 loginUseCaseProvider。
-原则:app/di/ 只放 SDK 基础设施(ApiConfig / ApiClient),业务模块的 DI 链路(Repository → UseCase 按需)内聚在 features/{模块}/di/{模块}_providers.dart 中。
+原则:app/di/ 只放 SDK 基础设施(ApiConfig / NetworksSdkApi),业务模块的 DI 链路(Repository → UseCase 按需)内聚在 features/{模块}/di/{模块}_providers.dart 中。
3.2 编写 ViewModel
@@ -6280,7 +6295,7 @@ class LoginViewModel extends _$LoginViewModel {
→ View: vm.doSomething(...)
→ ViewModel: ref.read(xxxRepositoryProvider).doSomething(...)
→ RepositoryImpl.doSomething() // data/repositories/
- → _client.executeRequest(XxxRequest) // 直接调 ApiClient
+ → _client.executeRequest(XxxRequest) // 调 NetworksSdkApi
→ 自动注入 header → HTTP 请求 → 自动解码 → DTO
→ dto.toEntity() → Domain Entity
← state = state.copyWith(...) // 更新状态
@@ -6295,8 +6310,8 @@ class LoginViewModel extends _$LoginViewModel {
→ LoginUseCase: 格式校验(邮箱 + 密码) // features/auth/usecases/
→ LoginUseCase: authRepository.login(...)
→ AuthRepositoryImpl.login() // data/repositories/
- → _client.executeRequest(LoginRequest) // 直接调 ApiClient
- → AuthInterceptor → Dio.request → RetryInterceptor // 自动处理
+ → _client.executeRequest(LoginRequest) // 调 NetworksSdkApi
+ → Auth → Encryption → Dio.request → Retry → Logging // 拦截器链自动处理
← request.decodeResponse → LoginData.fromJson // 自动解码
← LoginData(DTO)
→ onTokenUpdate(token) // 回调:内存写入 + 持久化
@@ -6360,7 +6375,7 @@ class SendMessageRequest extends ApiRequestable<SendMessageData>
}
-保存 → 自动生成 → 然后在 Repository 中直接调 ApiClient 就完了:
+保存 → 自动生成 → 然后在 Repository 中调 NetworksSdkApi 就完了:
// 在 MessageRepositoryImpl 中添加
Future<SendMessageData?> sendMessage({
@@ -6568,18 +6583,18 @@ final apiConfigProvider = Provider<ApiConfig>((ref) {
);
});
-/// API 客户端 Provider(全局单例)
-/// 内部自动挂载 AuthInterceptor / RetryInterceptor / LoggingInterceptor
-final apiClientProvider = Provider<ApiClient>((ref) {
+/// Networks SDK API Provider(全局单例,Facade 接口)
+/// 内部自动挂载 AuthInterceptor / EncryptionInterceptor / RetryInterceptor / LoggingInterceptor
+final networkSdkApiProvider = Provider<NetworksSdkApi>((ref) {
final config = ref.read(apiConfigProvider);
- return ApiClient(config: config);
+ return NetworksSdkWiring.build(config: config);
});
DI 装配总览
app/di/ ← 手动装配:SDK 基础设施
-└── network_provider.dart → apiConfigProvider + apiClientProvider
+└── network_provider.dart → apiConfigProvider + networkSdkApiProvider
features/{模块}/di/ ← 手动装配:业务模块 DI 链路(Repository → UseCase 按需)
├── auth/di/auth_providers.dart → authRepositoryProvider
@@ -6593,7 +6608,7 @@ features/{模块}/presentation/ ← @riverpod 自动生成:ViewModel
di/ 目录的定位:只放需要手动装配的 Provider(构造注入、回调组合等)。ViewModel Provider 由 @riverpod 注解自动生成(写在 presentation/ 下),不在 di/ 中。
-最小化原则:app/di/ 只提供 SDK 能力(ApiConfig / ApiClient),不放业务模块的 Provider。每个业务模块的手动装配 Provider 内聚在 features/{模块}/di/{模块}_providers.dart 中,需要时才创建。
+最小化原则:app/di/ 只提供 SDK 能力(ApiConfig / NetworksSdkApi),不放业务模块的 Provider。每个业务模块的手动装配 Provider 内聚在 features/{模块}/di/{模块}_providers.dart 中,需要时才创建。
SDK 间解耦:回调注入模式
@@ -6605,7 +6620,7 @@ final authRepositoryProvider = Provider<AuthRepository>((ref) {
// final secureStorage = ref.read(secureStorageProvider); // storage_sdk(待接入)
return AuthRepositoryImpl(
- client: ref.read(apiClientProvider), // 直接注入 ApiClient
+ client: ref.read(networkSdkApiProvider), // 注入 Facade 接口
onTokenUpdate: (token) {
apiConfig.updateToken(token); // 内存(networks_sdk)
// secureStorage.saveToken(token); // 持久化(storage_sdk,待接入)
@@ -6635,6 +6650,7 @@ final authRepositoryProvider = Provider<AuthRepository>((ref) {
networkError: (msg) => showToast('网络错误: $msg'),
decodingError: (msg) => showToast('数据解析失败'),
apiError: (code, msg) => showToast('服务端错误[$code]: $msg'),
+ cancelled: () => {}, // 用户主动取消,通常不提示
unknown: (msg) => showToast('未知错误'),
);
}
@@ -7137,9 +7153,9 @@ abstract class ProfileRepository {
// data/repositories/profile_repository_impl.dart
class ProfileRepositoryImpl implements ProfileRepository {
- final ApiClient _client;
+ final NetworksSdkApi _client;
- ProfileRepositoryImpl({required ApiClient client})
+ ProfileRepositoryImpl({required NetworksSdkApi client})
: _client = client;
@override
@@ -7166,7 +7182,7 @@ import '../../../app/di/network_provider.dart';
// ── Repository ──
final profileRepositoryProvider = Provider<ProfileRepository>((ref) {
return ProfileRepositoryImpl(
- client: ref.read(apiClientProvider), // 直接注入 ApiClient
+ client: ref.read(networkSdkApiProvider), // 注入 Facade 接口
);
});
@@ -7174,7 +7190,7 @@ final profileRepositoryProvider = Provider<ProfileRepository>((ref) {
// ViewModel 通过 @riverpod 注解自动生成 Provider,无需额外注册。
-说明:Profile 属于简单模板,ViewModel 直接调 Repository,无需 UseCase 中间层。app/di/ 只提供 SDK 基础设施(ApiConfig / ApiClient),业务模块的 DI 链路内聚在 Feature 目录下。
+说明:Profile 属于简单模板,ViewModel 直接调 Repository,无需 UseCase 中间层。app/di/ 只提供 SDK 基础设施(ApiConfig / NetworksSdkApi),业务模块的 DI 链路内聚在 Feature 目录下。
Feature 结构图示
@@ -7714,7 +7730,7 @@ sequenceDiagram
participant Repo as domain/repositories/
message_repository.dart
participant RepoImpl as data/repositories/
message_repository_impl.dart
participant LocalDS as data/local/
message_local_ds.dart
- participant SDK as networks_sdk/
ApiClient / SocketClient
+ participant SDK as networks_sdk/
NetworksSdkApi / SocketClient
participant WS as WebSocket Server
UI->>VM: 1. 用户点击发送按钮
@@ -7746,7 +7762,7 @@ sequenceDiagram
Repository 接口:UseCase 通过 domain/repositories/message_repository.dart 接口调用
Repository 实现:data/repositories/message_repository_impl.dart 实现具体逻辑
本地优先:先保存到 data/local/message_local_ds.dart
-网络发送:Repository 直接调 SDK(ApiClient / SocketClient)发送
+网络发送:Repository 调 SDK(NetworksSdkApi / SocketClient)发送
服务器确认:WebSocket 服务器确认接收
状态更新:更新本地数据库中的消息状态
数据返回:层层返回,最终更新 UI
@@ -7830,7 +7846,7 @@ abstract class MessageRepository {
// data/repositories/message_repository_impl.dart
class MessageRepositoryImpl implements MessageRepository {
final MessageLocalDataSource _localDS;
- final ApiClient _client; // 直接注入 ApiClient / SocketClient
+ final NetworksSdkApi _client; // 注入 Facade 接口
MessageRepositoryImpl(this._localDS, this._client);
@@ -7861,7 +7877,7 @@ class MessageRepositoryImpl implements MessageRepository {
MessageRepository messageRepository(MessageRepositoryRef ref) {
return MessageRepositoryImpl(
ref.watch(messageLocalDataSourceProvider),
- ref.watch(apiClientProvider), // 直接注入 ApiClient
+ ref.watch(networkSdkApiProvider), // 注入 Facade 接口
);
}
@@ -7895,7 +7911,7 @@ sequenceDiagram
participant RepoImpl as data/repositories/
chat_repository_impl.dart
participant Cache as data/cache/
cache_manager.dart
participant LocalDS as data/local/
chat_local_ds.dart
- participant SDK as networks_sdk/
ApiClient
+ participant SDK as networks_sdk/
NetworksSdkApi
UI->>VM: 1. 页面初始化
VM->>UC: 2. 调用 LoadChatListUseCase
@@ -7907,7 +7923,7 @@ sequenceDiagram
else 缓存未命中
RepoImpl->>LocalDS: 6b. 读取本地数据库
LocalDS-->>RepoImpl: 7. 返回本地数据
- RepoImpl->>SDK: 8. 直接调 ApiClient 请求远程数据
+ RepoImpl->>SDK: 8. 调 NetworksSdkApi 请求远程数据
SDK-->>RepoImpl: 9. 返回最新数据
RepoImpl->>LocalDS: 10. 更新本地数据库
RepoImpl->>Cache: 11. 更新缓存
@@ -8583,9 +8599,9 @@ abstract class ChatRepository {
Future<void> sendMessage(Message message);
}
-// 2. Repository 实现层(直接注入 ApiClient)
+// 2. Repository 实现层(注入 Facade 接口)
class ChatRepositoryImpl implements ChatRepository {
- final ApiClient client;
+ final NetworksSdkApi client;
final LocalDataSource localDataSource;
final MessageMapper mapper;
@@ -8603,7 +8619,7 @@ class ChatRepositoryImpl implements ChatRepository {
return localDTOs.map(mapper.toEntity).toList();
}
- // 直接调 ApiClient 从远程获取
+ // 调 NetworksSdkApi 从远程获取
final response = await client.executeRequest(
GetMessagesRequest(chatId: chatId),
);
@@ -9770,6 +9786,146 @@ flowchart TD
单一职责:每个模块只做一件事,UseCase/ViewModel/Repository 各司其职
+
+
+第八部分:UI 设计规范
+
+本章定义颜色、字体、组件、弹框、图标的命名与使用规则,明确设计与研发的协作约定。Figma 按此命名,代码按此封装,两端名称一一对应。
+
+8.0 核心约定
+
+
+全局只有一份
+颜色、字体、基础组件、业务弹框、图片、图标——Figma 里每种元素只有一个定义。没有"备用版本",没有"临时副本",不允许两个"差不多一样"的组件并存。
+
+
+
+- Figma 命名是重中之重:点中任何元素都必须看到抽象名称。六类无例外:
+
+ 颜色 — 如 primary、surface
+ 字体 — 如 Body/Medium、Label/Small
+ 基础组件 — 如 Button/Primary、Input/Default,全局只有一个版本
+ 业务弹框 — 如 Dialog/Confirm
+ 图片 — Figma 统一导出,代码侧 AppAssets 注册,不硬编码路径
+ 图标 — 如 send、more_options,代码侧 AppIcons 调用
+
+
+- 基础组件定稿后不随意改动:需改动时必须先告知研发,评估影响范围,双方同步后再执行
+- UI 团队自主维护 UI 基建体系:研发照着 Figma 名字封装,名称必须完全一致
+- 所有元素遵循同一套命名规则:新增任何元素先在 Figma 定名,研发用相同名字注册
+
+
+
+图片和组件是重灾区:没有统一来源时,不同研发各自导出同一张图,文件名不同、尺寸不同,最终项目里堆满重复文件。Figma 统一命名、代码统一注册,才能从源头堵住。
+
+
+8.1 颜色体系
+
+所有颜色通过抽象名称引用。抽象名在亮色 / 暗色两套主题下对应不同色值,修改主题只需改映射表,不需逐个找组件。
+
+语义色(随主题变化)
+
+
+
+抽象名 Figma 名 亮色 暗色 用途
+
+
+primaryPrimary #2F80ED #5BA3F5 主操作、链接、选中态
+backgroundBackground #F8F9FA #202124 页面底色
+surfaceSurface #FFFFFF #3C4043 卡片、弹框、输入框
+onSurfaceOn Surface #202124 #FFFFFF surface 上的文字
+errorError #EB5757 错误状态
+successSuccess #27AE60 成功状态
+warningWarning #F2C94C 警告状态
+
+
+
+灰阶(固定值,不随主题变化)
+
+
+名称 色值 名称 色值 名称 色值
+
+white #FFFFFF gray50 #F8F9FA gray100 #F1F3F4
+gray200 #E8EAED gray400 #BDC1C6 gray600 #80868B
+gray800 #3C4043 gray900 #202124 black #000000
+
+
+
+使用原则:需随主题切换 → 用语义色(primary、surface);亮暗保持不变 → 用灰阶固定值。
+
+8.2 字体体系
+
+字体按层级分五档:Display、Headline、Title、Body、Label,每档三个尺寸。Figma 中按 层级/尺寸 格式命名(如 Body/Large),开发用同名变量调用。
+
+
+Figma 名称 字号 字重 行高 字距 典型用途
+
+DISPLAY
+Display/Large 57 400 64 -0.25 启动页大标题、空状态
+Display/Medium 45 400 52 — —
+Display/Small 36 400 44 — —
+HEADLINE
+Headline/Large 32 400 40 — 页面主标题、导航栏
+Headline/Medium 28 400 36 — —
+Headline/Small 24 400 32 — —
+TITLE
+Title/Large 22 500 28 — 会话列表名称、设置项标题
+Title/Medium 16 500 24 0.15 卡片标题、列表主行
+Title/Small 14 500 20 0.1 —
+BODY
+Body/Large 16 400 24 0.5 聊天气泡、表单输入
+Body/Medium 14 400 20 0.25 正文说明、列表副行
+Body/Small 12 400 16 0.4 辅助信息、提示文字
+LABEL
+Label/Large 14 500 20 0.1 按钮文字、Tab 标签、Badge
+Label/Medium 12 500 16 0.5 次要标签、徽标文字
+Label/Small 11 500 16 0.5 最小粒度标签
+语义样式
+Section Label 13 600 — 0.5 列表分组标题、设置分区
+Body/Muted 12 400 16 — 说明文字(灰色,低对比度)
+Body/Error 12 400 16 — 表单错误提示(红色)
+Label/Muted 12 500 16 — 时间戳、元数据(低对比度)
+
+
+
+8.3 组件 — Button
+
+按钮共四种变体,每种有明确使用场景和 Figma 组件名。每个页面上主操作只用一个 Primary。
+
+
+Figma 组件名 用途 亮色样式 暗色样式 状态
+
+Button/Primary主操作(登录、发送、确认),每屏最多一次 背景 #2F80ED,白字 背景 #5BA3F5,白字 默认 / Loading / 禁用(#BDC1C6)
+Button/Secondary次要操作(注册、稍后再说),描边样式 描边 #2F80ED,蓝字 描边 #5BA3F5,蓝字 默认 / 禁用
+Button/Text辅助链接(忘记密码、查看全部、弹框取消) 无背景,蓝字 无背景,蓝字 默认
+Button/Inverse反色按钮(深色背景高亮),支持左侧图标 背景 #202124,白字 背景 #FFFFFF,黑字 默认
+
+
+
+8.4 业务弹框 — Dialog
+
+当前封装一种通用确认弹框 Dialog/Confirm,后续新增先在 Figma 以 Dialog/ 前缀命名。
+
+
+项目 说明
+
+结构 标题(不超 15 字) + 内容 + 操作区(取消 Text 样式 | 确认 Primary 样式)
+可配置 标题文字、内容文字、确认/取消标签、点击背景是否可关闭
+返回值 确认 / 取消 / 关闭(点击背景)
+
+
+
+8.5 图标规范
+
+
+- UI 先命名,开发跟随:Figma 确定名称,开发用完全相同的名称封装到
AppIcons
+- 名称有实际语义:全小写下划线,如
send、add_contact、more_options。不用拼音,不缩写
+- 统一用 AppIcons 调用:不允许直接用裸 icon 库变量,替换时改一处全局生效
+- 同义图标只保留一个:同功能图标在整个产品内只存在一种
+
+
+新增图标流程:设计师 Figma 确认名称 → 告知开发 → 开发用相同名称在 AppIcons 注册。两端名称必须完全一致。
+
diff --git a/apps/im_app/android/settings.gradle.kts b/apps/im_app/android/settings.gradle.kts
index ca7fe06..3ca60d7 100644
--- a/apps/im_app/android/settings.gradle.kts
+++ b/apps/im_app/android/settings.gradle.kts
@@ -21,6 +21,7 @@ plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false
}
include(":app")
diff --git a/apps/im_app/ios/Podfile b/apps/im_app/ios/Podfile
index cf9087f..9100f59 100644
--- a/apps/im_app/ios/Podfile
+++ b/apps/im_app/ios/Podfile
@@ -38,5 +38,10 @@ end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
+ target.build_configurations.each do |config|
+ # 对没有在 podspec 里声明 swift_version 的 pod 设置兜底版本。
+ # 已有 swift_version 的 pod(含第三方如 Agora)CocoaPods 优先使用其 podspec 值,不受影响。
+ config.build_settings['SWIFT_VERSION'] ||= '6.2'
+ end
end
end
diff --git a/apps/im_app/ios/Runner.xcodeproj/project.pbxproj b/apps/im_app/ios/Runner.xcodeproj/project.pbxproj
index 2bf7cf1..e2932a4 100644
--- a/apps/im_app/ios/Runner.xcodeproj/project.pbxproj
+++ b/apps/im_app/ios/Runner.xcodeproj/project.pbxproj
@@ -164,7 +164,6 @@
1C416905D0EA345032C4E612 /* Pods-RunnerTests.release.xcconfig */,
9538107A41BCB5B5D84FBAF3 /* Pods-RunnerTests.profile.xcconfig */,
);
- name = Pods;
path = Pods;
sourceTree = "";
};
@@ -489,7 +488,7 @@
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
};
@@ -508,7 +507,7 @@
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.2;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
@@ -524,7 +523,7 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.2;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
@@ -540,7 +539,7 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.2;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
@@ -678,7 +677,7 @@
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
};
@@ -705,7 +704,7 @@
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
};
diff --git a/apps/im_app/ios/Runner/AppDelegate.swift b/apps/im_app/ios/Runner/AppDelegate.swift
index fe8e3fa..8d8a0bd 100644
--- a/apps/im_app/ios/Runner/AppDelegate.swift
+++ b/apps/im_app/ios/Runner/AppDelegate.swift
@@ -1,8 +1,8 @@
-import Flutter
+@preconcurrency import Flutter
import UIKit
@main
-@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
+@objc class AppDelegate: FlutterAppDelegate {
override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
@@ -16,9 +16,11 @@ import UIKit
sceneConfig.delegateClass = SceneDelegate.self
return sceneConfig
}
+}
- // MARK: - FlutterImplicitEngineDelegate
-
+// FlutterImplicitEngineDelegate 来自 Flutter ObjC 框架,尚未标注 @MainActor,
+// 用 @preconcurrency 抑制 Swift 6 ConformanceIsolation 错误。
+extension AppDelegate: @preconcurrency FlutterImplicitEngineDelegate {
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
diff --git a/apps/im_app/lib/app/di/network_provider.dart b/apps/im_app/lib/app/di/network_provider.dart
index e1792c1..92f485c 100644
--- a/apps/im_app/lib/app/di/network_provider.dart
+++ b/apps/im_app/lib/app/di/network_provider.dart
@@ -6,6 +6,8 @@ import 'package:networks_sdk/networks_sdk.dart';
import '../../core/foundation/api_paths.dart';
import '../../core/foundation/config.dart';
import '../../core/foundation/constants.dart';
+import '../../core/foundation/errors.dart';
+import '../../core/foundation/utils.dart';
import '../../core/services/network_monitor.dart';
import '../../core/services/socket_manager.dart';
@@ -47,6 +49,21 @@ final networkMonitorProvider = Provider((ref) {
return monitor;
});
+// ── Token 更新事件流 ─────────────────────────────────────────────────────────
+
+/// Token 更新事件流
+///
+/// apiConfigProvider.onTokenUpdated → 推送新 token 到此流
+/// socketManagerProvider → 监听此流 → 同步 token 到 WebSocket
+/// onBeforeReconnect 中刷新 token 后调用 apiConfig.updateToken → tokenStream.add,
+/// 需要同步传播到 socketManager.updateToken → socketClient._currentToken,
+/// 确保随后的 _doConnect() 使用新 token。异步模式下 _doConnect 会在 stream
+final _tokenUpdateStreamProvider = Provider>((ref) {
+ final controller = StreamController.broadcast(sync: true);
+ ref.onDispose(controller.close);
+ return controller;
+});
+
// ── HTTP 基础设施 ─────────────────────────────────────────────────────────────
/// API 配置 Provider(全局单例)
@@ -58,15 +75,18 @@ final networkMonitorProvider = Provider((ref) {
/// 请求前先判断网络状态,无网络时直接抛 [ApiError.noNetworkConnection]。
final apiConfigProvider = Provider((ref) {
final networkMonitor = ref.read(networkMonitorProvider);
+ final tokenStream = ref.read(_tokenUpdateStreamProvider);
return ApiConfig(
baseURL: AppConfig.apiBaseUrl,
platformHeaders: {
- 'Platform': 'Android', // TODO: 运行时从平台 API 获取
+ 'Platform': 'Android', // TODO: 运行时从 platform API 获取
'client-version': '1.0.0', // TODO: 运行时从 package_info 获取
+ 'Channel': '', // TODO: 从 AppConfig 读取渠道标识
+ 'lang': 'zh-CN', // TODO: 从 l10n_sdk 或系统 locale 动态获取
},
- tokenExpiredCodes: {30002, 30003, 30124},
- forceLogoutCodes: {30125},
+ tokenExpiredCodes: ApiErrorCodes.tokenExpiredCodes,
+ forceLogoutCodes: ApiErrorCodes.forceLogoutCodes,
onForceLogout: () {
// TODO: 清除登录态,跳转登录页
},
@@ -74,7 +94,17 @@ final apiConfigProvider = Provider((ref) {
// TODO: App 层刷新 token 逻辑
return null;
},
+ onTokenUpdated: (newToken) {
+ // 通过事件流同步到 WebSocket,避免直接引用 socketManagerProvider 造成循环依赖
+ tokenStream.add(newToken);
+ },
onCheckNetworkAvailable: () async => networkMonitor.isConnected,
+ onEncryptRequest: null, // TODO: 接入 cipher_guard_sdk 后注入请求加密回调
+ onDecryptResponse: null, // TODO: 接入 cipher_guard_sdk 后注入响应解密回调
+ onBusinessError: null, // TODO: 接入业务错误统一处理(弹窗 / Toast / 跳转等)
+ onTransformResponse:
+ null, // TODO: 如后端信封结构非标准,在此归一化为 { code, data, message }
+ onGetTokenExpiry: parseJwtExpiry,
maxRetries: AppConstants.maxRetries,
retryBaseDelay: AppConstants.retryBaseDelay,
onLog: (message, {tag}) {
@@ -94,16 +124,47 @@ final networkSdkApiProvider = Provider((ref) {
// ── WebSocket 基础设施 ────────────────────────────────────────────────────────
-/// SocketConfig Provider(全局单例)
+/// SocketConfig Provider(内部使用,不对外暴露)
///
/// 与 apiConfigProvider 对称,通过回调注入 App 层能力,
/// SDK 内部不调用其他 SDK。
-final socketConfigProvider = Provider((ref) {
+final _socketConfigProvider = Provider((ref) {
final networkMonitor = ref.read(networkMonitorProvider);
return SocketConfig(
maxReconnectAttempts: AppConstants.maxRetries,
maxReconnectDelay: AppConstants.maxReconnectDelay,
+ unlimitedReconnect: true, // IM 场景始终保持连接
+ onBuildConnectUrl:
+ null, // TODO: 接入 cipher_guard_sdk 后注入 WS URL 加密(路径/token/cipher 参数)
+ onEncryptMessage: null, // TODO: 接入 cipher_guard_sdk 后注入消息加密回调
+ onDecryptMessage: null, // TODO: 接入 cipher_guard_sdk 后注入消息解密回调
+ onBeforeReconnect: () async {
+ // SocketClient 内部重连(心跳超时、stream onDone)前调用。
+ // 与 SocketManager.onBeforeReconnect 职责相同:检查 token 并按需刷新。
+ // 刷新后通过 sync stream 同步传播到 SocketClient._currentToken,
+ // 确保随后的 _doConnect() 使用新 token。
+ final apiConfig = ref.read(apiConfigProvider);
+ final currentToken = apiConfig.token;
+ if (currentToken == null || apiConfig.onGetTokenExpiry == null) return;
+
+ final expiry = apiConfig.onGetTokenExpiry!(currentToken);
+ if (expiry == null) return;
+
+ final remaining = expiry.difference(DateTime.now());
+ if (remaining > apiConfig.proactiveRefreshThreshold) return;
+
+ // ignore: avoid_print
+ print(
+ '[Socket] Token expiring in ${remaining.inMinutes}min, refreshing before reconnect',
+ );
+ final newToken = await apiConfig.onTokenRefresh?.call();
+ if (newToken != null && newToken.isNotEmpty) {
+ // updateToken → onTokenUpdated → sync stream → manager.updateToken
+ // → _client.updateToken → socketClient._currentToken 同步更新
+ apiConfig.updateToken(newToken);
+ }
+ },
onLog: (message, {tag}) {
// ignore: avoid_print
print('[${tag ?? 'Socket'}] $message');
@@ -114,12 +175,11 @@ final socketConfigProvider = Provider((ref) {
);
});
-/// SocketClient Provider(全局单例)
+/// SocketClient Provider(内部使用,不对外暴露)
///
-/// 与 apiClientProvider 对称。
-final socketClientProvider = Provider((ref)
-{
- final config = ref.read(socketConfigProvider);
+/// 与 networkSdkApiProvider 对称。
+final _socketClientProvider = Provider((ref) {
+ final config = ref.read(_socketConfigProvider);
return NetworksMessagingApi()..initialize(config);
});
@@ -139,17 +199,43 @@ final socketClientProvider = Provider((ref)
/// 网络状态变化由 [networkMonitorProvider](公共服务)驱动,
/// 自动触发断连/重连。
///
+/// Token 更新由 [_tokenUpdateStreamProvider] 事件流驱动,
+/// HTTP 层刷新 token 后自动同步到 WebSocket。
+///
/// onMessageTransform 参考 HTTP 层 onTokenRefresh 的回调模式:
/// 后续接入加解密 SDK 时,在此注入解密回调,
/// SDK 内部不调用其他 SDK。
final socketManagerProvider = Provider((ref) {
- final client = ref.read(socketClientProvider);
+ final client = ref.read(_socketClientProvider);
final networkMonitor = ref.read(networkMonitorProvider);
+ final apiConfig = ref.read(apiConfigProvider);
+ final tokenStream = ref.read(_tokenUpdateStreamProvider);
final manager = SocketManager(
client: client,
wsUrl: _buildWsUrl(AppConfig.apiBaseUrl),
onMessageTransform: null, // TODO: 接入加解密 SDK 后注入解密回调
+ onBeforeReconnect: () async {
+ // 重连前检查 token 是否即将过期,是则主动刷新
+ final currentToken = apiConfig.token;
+ if (currentToken == null || apiConfig.onGetTokenExpiry == null) return;
+
+ final expiry = apiConfig.onGetTokenExpiry!(currentToken);
+ if (expiry == null) return;
+
+ final remaining = expiry.difference(DateTime.now());
+ if (remaining > apiConfig.proactiveRefreshThreshold) return;
+
+ // ignore: avoid_print
+ print(
+ '[SocketManager] Token expiring in ${remaining.inMinutes}min, refreshing before reconnect',
+ );
+ final newToken = await apiConfig.onTokenRefresh?.call();
+ if (newToken != null && newToken.isNotEmpty) {
+ // updateToken 触发 onTokenUpdated → tokenStream → socketManager.updateToken
+ apiConfig.updateToken(newToken);
+ }
+ },
onCheckNetworkAvailable: () async => networkMonitor.isConnected,
onLog: (message, {tag}) {
// ignore: avoid_print
@@ -157,13 +243,19 @@ final socketManagerProvider = Provider((ref) {
},
);
+ // 监听 token 更新事件 → 同步到 WebSocket
+ final tokenSub = tokenStream.stream.listen((newToken) {
+ manager.updateToken(newToken);
+ });
+
// 监听网络状态变化 → 驱动 SocketManager 断连/重连
- final subscription = networkMonitor.onStatusChanged.listen((isAvailable) {
+ final networkSub = networkMonitor.onStatusChanged.listen((isAvailable) {
manager.handleNetworkStatusChanged(isAvailable: isAvailable);
});
ref.onDispose(() {
- subscription.cancel();
+ tokenSub.cancel();
+ networkSub.cancel();
unawaited(manager.dispose());
});
@@ -215,23 +307,55 @@ String _buildWsUrl(String httpBaseUrl) {
// Provider 链路:
//
// networkMonitorProvider(公共服务,HTTP + WS 共用)
-// ├── apiConfigProvider → apiClientProvider ← HTTP 层
-// └── socketConfigProvider → socketClientProvider ← WS 层
+// ├── apiConfigProvider → networkSdkApiProvider ← HTTP 层
+// └── _socketConfigProvider → _socketClientProvider ← WS 层(内部)
// → socketManagerProvider
//
+// _tokenUpdateStreamProvider(打破循环引用的中间层)
+// ← apiConfigProvider.onTokenUpdated 推送
+// → socketManagerProvider 监听 → socketManager.updateToken()
+//
// 网络事件驱动链路:
//
// connectivity_plus(平台网络事件)
// → NetworkMonitor.onStatusChanged(true / false)
// → SocketManager.handleNetworkStatusChanged()
// → 断网: disconnect()
-// → 恢复: connect(token: lastToken)
+// → 恢复: onBeforeReconnect → connect(token: lastToken)
//
// 前后台事件驱动链路:
//
// WidgetsBindingObserver(App 层 app.dart)
-// → SocketManager.onEnterBackground() → disconnect
-// → SocketManager.onEnterForeground() → reconnect
+// → SocketManager.onEnterBackground()
+// disconnectInBackground=true → disconnect(默认,移动端省电)
+// disconnectInBackground=false → 完全保活,不断连不暂停心跳(桌面端)
+// → SocketManager.onEnterForeground() → onBeforeReconnect → reconnect
+//
+// Token 刷新 → WebSocket 同步链路:
+//
+// RetryInterceptor 检测 token 过期
+// → TokenRefreshManager.refreshIfNeeded()
+// → apiConfig.updateToken(newToken)
+// → onTokenUpdated(newToken)
+// → _tokenUpdateStream.add(newToken)
+// → socketManager.updateToken(newToken) // 不断连,下次重连自动用新 token
+//
+// 主动 token 刷新(重连前,两个层级):
+//
+// SocketManager 层(前台恢复 / 网络恢复触发):
+// SocketManager.onBeforeReconnect()
+// → 解析 JWT exp → 距过期 < 阈值
+// → apiConfig.onTokenRefresh() → 刷新
+// → apiConfig.updateToken(newToken)
+// → sync stream → manager.updateToken → _lastToken 更新
+// → _client.connect(token: _lastToken) 使用新 token
+//
+// SocketClient 层(心跳超时 / stream onDone 触发):
+// SocketConfig.onBeforeReconnect()
+// → 同上逻辑:检查 JWT exp → 刷新 → apiConfig.updateToken
+// → sync stream → manager.updateToken → _client.updateToken
+// → socketClient._currentToken 同步更新
+// → _doConnect() 使用新 token
//
// Repository 直接注入 ApiClient,通过回调注入其他 SDK 能力:
//
@@ -313,7 +437,7 @@ String _buildWsUrl(String httpBaseUrl) {
// final authRepositoryProvider = Provider((ref) {
// final apiConfig = ref.read(apiConfigProvider);
// return AuthRepositoryImpl(
-// client: ref.read(apiClientProvider), // 直接注入
+// client: ref.read(networkSdkApiProvider), // 注入 Facade 接口
// onTokenUpdate: (token) {
// apiConfig.updateToken(token); // 内存(network_sdk)
// // secureStorage.saveToken(token); // 持久化(crypto_sdk)
diff --git a/apps/im_app/lib/core/foundation/errors.dart b/apps/im_app/lib/core/foundation/errors.dart
index e69de29..983c096 100644
--- a/apps/im_app/lib/core/foundation/errors.dart
+++ b/apps/im_app/lib/core/foundation/errors.dart
@@ -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 tokenExpiredCodes = {
+ tokenInvalid,
+ jwtInvalid,
+ sessionInvalid,
+ };
+
+ /// 强制登出错误码集合 — 触发退出登录流程
+ static const Set forceLogoutCodes = {refreshTokenFailed};
+
+ /// 踢下线错误码集合 — 触发踢下线 UI 提示
+ static const Set kickOffCodes = {
+ loggedInAnotherDevice,
+ signingMethodError,
+ parsingKeyError,
+ };
+}
diff --git a/apps/im_app/lib/core/foundation/utils.dart b/apps/im_app/lib/core/foundation/utils.dart
index e69de29..707912c 100644
--- a/apps/im_app/lib/core/foundation/utils.dart
+++ b/apps/im_app/lib/core/foundation/utils.dart
@@ -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) return null;
+
+ final exp = payload['exp'];
+ if (exp is! int) return null;
+ return DateTime.fromMillisecondsSinceEpoch(exp * 1000);
+ } catch (_) {
+ return null;
+ }
+}
diff --git a/apps/im_app/lib/core/services/socket_manager.dart b/apps/im_app/lib/core/services/socket_manager.dart
index 0049616..a1128dd 100644
--- a/apps/im_app/lib/core/services/socket_manager.dart
+++ b/apps/im_app/lib/core/services/socket_manager.dart
@@ -1,6 +1,5 @@
import 'dart:async';
-
import 'package:networks_sdk/networks_sdk.dart';
import 'network_backoff_debouncer.dart';
@@ -10,9 +9,8 @@ import 'network_backoff_debouncer.dart';
/// 参考 HTTP 层 onTokenRefresh 的回调注入模式。
/// App 层在 Provider 装配时注入解密/解析逻辑,
/// 不在 SDK 内部调用加解密 SDK。
-typedef MessageTransformer = Map Function(
- Map raw,
-);
+typedef MessageTransformer =
+ Map Function(Map raw);
/// WebSocket 连接管理
///
@@ -39,19 +37,26 @@ typedef MessageTransformer = Map Function(
///
/// ```
/// 登录成功 → connect(token) → 前置检查 → 建立连接
-/// App 进后台 → onEnterBackground() → 断开连接(省电)
-/// App 回前台 → onEnterForeground() → 检查网络 → 自动重连
+///
+/// ── disconnectInBackground = true(默认,移动端)──
+/// App 进后台 → onEnterBackground() → 暂停心跳 + 断开连接(省电)
+/// App 回前台 → onEnterForeground() → 恢复心跳 → onBeforeReconnect → 重连
+///
+/// ── disconnectInBackground = false(桌面端)──
+/// App 进后台 → onEnterBackground() → 不操作,完全保活
+/// App 回前台 → onEnterForeground() → 不操作(连接始终在线)
+///
/// 网络丢失 → handleNetworkLost() → 断开连接
-/// 网络恢复 → handleNetworkRestored() → 退避重连(防抖动)
+/// 网络恢复 → handleNetworkRestored() → 退避 → onBeforeReconnect → 重连
/// 登出 → disconnect() → 断开连接,清除 token
/// ```
///
/// ## 前置检查策略
///
/// 所有会发起网络操作的方法都先检查前置条件:
-/// - connect → 检查网络可用性 + 是否在后台
-/// - send / sendString → 检查连接状态 + 是否在后台
-/// - onEnterForeground 重连 → 检查网络可用性
+/// - connect → 检查网络可用性 + 是否在后台(仅 disconnectInBackground=true 时拦截)
+/// - send / sendString → 检查连接状态 + 是否在后台(仅 disconnectInBackground=true 时拦截)
+/// - onEnterForeground / 网络恢复重连 → 检查网络可用性 + onBeforeReconnect
class SocketManager {
final NetworksMessagingApi _client;
final String _wsUrl;
@@ -70,6 +75,22 @@ class SocketManager {
/// 连接和重连前调用,无网络时跳过操作并标记恢复时重试。
final Future Function()? onCheckNetworkAvailable;
+ /// 重连前回调
+ ///
+ /// 在 WebSocket 重连前调用(前台恢复、网络恢复),App 层用于:
+ /// - 检查并刷新即将过期的 token
+ /// - 更新连接参数
+ ///
+ /// 回调完成后才发起实际重连。
+ final Future Function()? onBeforeReconnect;
+
+ /// 进后台时是否断开连接
+ ///
+ /// true(默认)— 后台断连省电,由 push 通知兜底,前台恢复时自动重连。
+ /// false — 后台保持连接(适用于桌面端或需要后台实时推送的场景)。
+ /// 设为 false 时,后台仅暂停心跳,不主动断连。
+ final bool disconnectInBackground;
+
/// 日志回调
final void Function(String message, {String? tag})? onLog;
@@ -104,10 +125,12 @@ class SocketManager {
required NetworksMessagingApi client,
required String wsUrl,
this.onMessageTransform,
+ this.onBeforeReconnect,
+ this.disconnectInBackground = true,
this.onCheckNetworkAvailable,
this.onLog,
- }) : _client = client,
- _wsUrl = wsUrl;
+ }) : _client = client,
+ _wsUrl = wsUrl;
// ── 连接 ──────────────────────────────────────────────────────────────────
@@ -124,8 +147,8 @@ class SocketManager {
_reconnectOnForeground = false;
_reconnectOnNetworkRestore = false;
- // 前置检查:在后台不连接(省电)
- if (_isInBackground) {
+ // 前置检查:移动端模式下在后台不连接(省电)
+ if (_isInBackground && disconnectInBackground) {
_reconnectOnForeground = true;
_log('In background, defer connect to foreground');
return false;
@@ -165,26 +188,47 @@ class SocketManager {
/// 当前是否在后台
bool get isInBackground => _isInBackground;
+ /// Token 热更新
+ ///
+ /// 透传给 SocketClient,仅更新内部 token,不断开连接。
+ /// 适用于 HTTP 层 token 刷新后同步到 WebSocket 的场景。
+ void updateToken(String token) {
+ _lastToken = token;
+ _client.updateToken(token);
+ _log('Token updated via SocketManager');
+ }
+
// ── 前后台生命周期 ────────────────────────────────────────────────────────
//
- // 后台 → 断连(省电省流量)
+ // 后台 → 断连(省电省流量)或保持连接(桌面端)
// 前台 → 自动重连(如果之前有连接)
- /// App 进后台 → 断开连接,标记前台恢复时重连
+ /// App 进后台
///
/// 由 App 层 WidgetsBindingObserver 在 [AppLifecycleState.paused] 时调用。
- /// 后台保持连接会消耗电量和流量,断开后由 push 通知兜底。
+ ///
+ /// [disconnectInBackground] 为 true 时(默认,移动端):
+ /// 断开连接 + 暂停心跳,由 push 通知兜底,前台恢复时自动重连。
+ ///
+ /// [disconnectInBackground] 为 false 时(桌面端):
+ /// 不断连、不暂停心跳,WebSocket 完全保活。
void onEnterBackground() {
_isInBackground = true;
// 取消待执行的前台重连(防止快速 前台→后台 切换导致后台建连)
_foregroundReconnectTimer?.cancel();
_foregroundReconnectTimer = null;
- // 同步 SocketClient 内部状态(与 onEnterForeground 对称)
+
+ if (!disconnectInBackground) {
+ // 桌面端模式:不断连、不暂停心跳,完全保活
+ _log('Entering background, keeping connection alive');
+ return;
+ }
+
+ // 移动端模式:通知 SocketClient 进后台(暂停心跳)
_client.onEnterBackground();
if (_lastToken == null) return; // 未登录,无需处理
- // 与 _handleNetworkLost 保持一致:
// 不仅 connected,connecting / reconnecting 也要断开,
// 防止 SocketClient 在后台继续尝试连接浪费电量和流量。
if (_client.isConnected ||
@@ -202,7 +246,11 @@ class SocketManager {
/// 重连前检查网络可用性,无网络时延迟到网络恢复事件再连。
void onEnterForeground() {
_isInBackground = false;
- _client.onEnterForeground();
+
+ // 只在移动端模式(后台曾断连/暂停心跳)时通知 SocketClient 恢复
+ if (disconnectInBackground) {
+ _client.onEnterForeground();
+ }
if (_reconnectOnForeground && _lastToken != null) {
_reconnectOnForeground = false;
@@ -226,7 +274,12 @@ class SocketManager {
_log('Network unavailable, defer reconnect to network restore');
return;
}
- _client.connect(_wsUrl, token: _lastToken!);
+ // 重连前钩子:刷新即将过期的 token 等
+ await onBeforeReconnect?.call();
+ // token 可能被 onBeforeReconnect 更新(通过 updateToken 链路同步)
+ if (_lastToken != null && !_client.isConnected) {
+ _client.connect(_wsUrl, token: _lastToken!);
+ }
}
},
);
@@ -275,18 +328,22 @@ class SocketManager {
if (_reconnectOnNetworkRestore && _lastToken != null) {
_reconnectOnNetworkRestore = false;
- // 在后台不重连,等前台恢复时再连
- if (_isInBackground) {
+ // 移动端模式:在后台不重连,等前台恢复时再连
+ if (_isInBackground && disconnectInBackground) {
_reconnectOnForeground = true;
_log('Network restored but in background, defer to foreground');
return;
}
_log('Network restored, scheduling reconnect with backoff');
- _networkDebouncer.call(() {
+ _networkDebouncer.call(() async {
if (!_client.isConnected && _lastToken != null && !_isInBackground) {
- _log('Backoff timer fired, reconnecting');
- _client.connect(_wsUrl, token: _lastToken!);
+ // 重连前钩子:刷新即将过期的 token 等
+ await onBeforeReconnect?.call();
+ if (!_client.isConnected && _lastToken != null && !_isInBackground) {
+ _log('Backoff timer fired, reconnecting');
+ _client.connect(_wsUrl, token: _lastToken!);
+ }
}
});
}
@@ -308,6 +365,9 @@ class SocketManager {
/// 原始消息流(不经预处理,调试用)
Stream get rawMessageStream => _client.rawMessageStream;
+ /// 二进制消息流
+ Stream get binaryMessageStream => _client.binaryMessageStream;
+
/// 连接状态变化流
Stream get connectionStateStream =>
_client.connectionStateStream;
@@ -333,6 +393,14 @@ class SocketManager {
return _client.sendString(message);
}
+ /// 发送二进制数据
+ ///
+ /// 前置检查:未连接或在后台时不发送。
+ Future sendBytes(List bytes) {
+ if (!_canSend()) return Future.value(false);
+ return _client.sendBytes(bytes);
+ }
+
// ── 释放 ──────────────────────────────────────────────────────────────────
/// 释放所有资源
@@ -355,7 +423,7 @@ class SocketManager {
_log('Not connected, cannot send');
return false;
}
- if (_isInBackground) {
+ if (_isInBackground && disconnectInBackground) {
_log('In background, skip send');
return false;
}
diff --git a/apps/im_app/lib/core/ui/base/assets.dart b/apps/im_app/lib/core/ui/base/assets.dart
new file mode 100644
index 0000000..5b6122f
--- /dev/null
+++ b/apps/im_app/lib/core/ui/base/assets.dart
@@ -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';
+}
diff --git a/apps/im_app/lib/core/ui/base/icons.dart b/apps/im_app/lib/core/ui/base/icons.dart
new file mode 100644
index 0000000..eb1bb6a
--- /dev/null
+++ b/apps/im_app/lib/core/ui/base/icons.dart
@@ -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;
+}
diff --git a/apps/im_app/lib/features/chat/presentation/chat_db_test_view_model.dart b/apps/im_app/lib/features/chat/presentation/chat_db_test_view_model.dart
index da6fa16..14f44d3 100644
--- a/apps/im_app/lib/features/chat/presentation/chat_db_test_view_model.dart
+++ b/apps/im_app/lib/features/chat/presentation/chat_db_test_view_model.dart
@@ -1,7 +1,6 @@
import 'dart:math';
import 'package:drift/drift.dart';
-import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:im_app/app/di/db_provider.dart';
import 'package:im_app/data/local/drift/app_database.dart';
@@ -45,13 +44,12 @@ class ChatDbTestState {
@riverpod
class ChatDbTestViewModel extends _$ChatDbTestViewModel {
-
@override
ChatDbTestState build() {
// 这里就是 onInit
final List testResults = List.generate(
1000,
- (i) => TestResult(
+ (i) => TestResult(
title: '用户 ${Random().nextInt(9999)}',
subtitle: 'uid: ${Random().nextInt(999999)}',
duration: '${Random().nextInt(500)}ms',
@@ -86,7 +84,7 @@ class ChatDbTestViewModel extends _$ChatDbTestViewModel {
for (var i = 0; i < count; i += chunkSize) {
final chunk = List.generate(
chunkSize.clamp(0, count - i),
- (j) => UsersCompanion.insert(
+ (j) => UsersCompanion.insert(
uid: Value(i + j),
nickname: Value('User ${i + j}'),
),
@@ -98,13 +96,13 @@ class ChatDbTestViewModel extends _$ChatDbTestViewModel {
// 让出主线程
await Future.delayed(Duration.zero);
- debugPrint('已完成: $completed / $count (${stopwatch.elapsedMilliseconds}ms)');
+ debugPrint(
+ '已完成: $completed / $count (${stopwatch.elapsedMilliseconds}ms)',
+ );
// 更新 UI 状态
if (ref.mounted) {
- state = state.copyWith(
- currentState: '已插入 $completed / $count 条',
- );
+ state = state.copyWith(currentState: '已插入 $completed / $count 条');
}
}
@@ -116,4 +114,4 @@ class ChatDbTestViewModel extends _$ChatDbTestViewModel {
);
}
}
-}
\ No newline at end of file
+}
diff --git a/apps/im_app/lib/features/chat/view/chat_detail_page.dart b/apps/im_app/lib/features/chat/view/chat_detail_page.dart
index 7f2be5d..23709ec 100644
--- a/apps/im_app/lib/features/chat/view/chat_detail_page.dart
+++ b/apps/im_app/lib/features/chat/view/chat_detail_page.dart
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/ui/base/context_theme_ext.dart';
@@ -12,7 +13,7 @@ import '../../../../core/ui/base/context_theme_ext.dart';
///
/// 将 [conversationId] 传给对应的 Riverpod `.family` provider 加载完整会话数据。
/// 构造参数保持不变,数据来源从 `extra` 换成 provider 即可。
-class ChatDetailPage extends StatelessWidget {
+class ChatDetailPage extends ConsumerWidget {
const ChatDetailPage({
super.key,
required this.conversationId,
@@ -23,7 +24,7 @@ class ChatDetailPage extends StatelessWidget {
final String title;
@override
- Widget build(BuildContext context) {
+ Widget build(BuildContext context, WidgetRef ref) {
final s = context.styles;
return Scaffold(
diff --git a/apps/im_app/lib/features/chat/view/chat_page.dart b/apps/im_app/lib/features/chat/view/chat_page.dart
index ce264a2..590a65b 100644
--- a/apps/im_app/lib/features/chat/view/chat_page.dart
+++ b/apps/im_app/lib/features/chat/view/chat_page.dart
@@ -20,8 +20,6 @@ class ChatPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final vm = ref.read(chatViewModelProvider.notifier);
-
return Scaffold(
appBar: AppBar(title: const Text('聊天')),
body: Center(
@@ -32,36 +30,48 @@ class ChatPage extends ConsumerWidget {
// 切换 Tab:用 go,替换整个历史栈,不可返回
AppButton.inverse(
label: '切换 Tab(go)',
- onPressed: () => vm.goToContact(context),
+ onPressed: () =>
+ ref.read(chatViewModelProvider.notifier).goToContact(context),
),
// 带参数 push:extra 传 Dart Record,适合已有对象的场景
AppButton.inverse(
label: '有参 push(extra)',
- onPressed: () => vm.pushChatDetailWithExtra(context),
+ onPressed: () => ref
+ .read(chatViewModelProvider.notifier)
+ .pushChatDetailWithExtra(context),
),
// 带参数 push:id 内嵌在路径中,适合需要深链接 / 分享的场景
AppButton.inverse(
label: '有参 push(路径参数)',
- onPressed: () => vm.pushChatDetailById(context),
+ onPressed: () => ref
+ .read(chatViewModelProvider.notifier)
+ .pushChatDetailById(context),
),
// 无参 push:压栈,自动显示返回按钮,不切 Tab
AppButton.inverse(
label: '无参 push',
- onPressed: () => vm.pushSettingsTheme(context),
+ onPressed: () => ref
+ .read(chatViewModelProvider.notifier)
+ .pushSettingsTheme(context),
),
// 无参 go:替换历史,切换到对应 Tab,TabBar 可见,不可返回
AppButton.inverse(
label: '无参 go',
- onPressed: () => vm.goToSettings(context),
+ onPressed: () => ref
+ .read(chatViewModelProvider.notifier)
+ .goToSettings(context),
),
AppButton.inverse(
label: '测试数据库性能',
- onPressed: () => vm.goToDatabaseTest(context),
+ onPressed: () => ref
+ .read(chatViewModelProvider.notifier)
+ .goToDatabaseTest(context),
),
AppButton.secondary(
label: '退出登录',
fullWidth: false,
- onPressed: () => vm.logout(),
+ onPressed: () =>
+ ref.read(chatViewModelProvider.notifier).logout(),
),
],
),
diff --git a/apps/im_app/lib/features/contact/view/contact_page.dart b/apps/im_app/lib/features/contact/view/contact_page.dart
index 7e819a1..d4a4912 100644
--- a/apps/im_app/lib/features/contact/view/contact_page.dart
+++ b/apps/im_app/lib/features/contact/view/contact_page.dart
@@ -1,13 +1,14 @@
import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 联系人页占位
///
/// 待 contact 功能开发后替换为实际内容。
-class ContactPage extends StatelessWidget {
+class ContactPage extends ConsumerWidget {
const ContactPage({super.key});
@override
- Widget build(BuildContext context) {
+ Widget build(BuildContext context, WidgetRef ref) {
return const Scaffold();
}
}
diff --git a/apps/im_app/lib/features/login/di/auth_providers.dart b/apps/im_app/lib/features/login/di/auth_providers.dart
index 10a8068..8802f93 100644
--- a/apps/im_app/lib/features/login/di/auth_providers.dart
+++ b/apps/im_app/lib/features/login/di/auth_providers.dart
@@ -12,7 +12,7 @@ import '../usecases/login_usecase.dart';
/// ViewModel Provider 由 `@riverpod` 注解自动生成,不在此文件中。
///
/// Auth 模块的 DI 链路:Repository → UseCase(按需)。
-/// app/di/ 只提供 SDK 基础设施(apiConfig / apiClient / socketManager / storageApi),
+/// app/di/ 只提供 SDK 基础设施(apiConfig / networkSdkApi / socketManager / storageApi),
/// 业务模块的 Provider 内聚在 features/{模块}/di/ 下。
///
/// ```
@@ -21,7 +21,7 @@ import '../usecases/login_usecase.dart';
/// → ref.read(authRepositoryProvider) ← di/ 手动装配
/// → ref.read(socketManagerProvider) ← app/di/ 手动装配
/// → ref.read(apiConfigProvider) ← app/di/ 手动装配
-/// → ref.read(apiClientProvider) ← app/di/ 手动装配
+/// → ref.read(networkSdkApiProvider) ← app/di/ 手动装配
/// → ref.read(storageSdkProvider) ← app/di/ 手动装配
/// ```
@@ -41,7 +41,7 @@ final authRepositoryProvider = Provider((ref) {
// TODO: final secureStorage = ref.read(secureStorageProvider);
return AuthRepositoryImpl(
- client: ref.read(networkSdkApiProvider), // 直接注入 ApiClient
+ client: ref.read(networkSdkApiProvider), // 注入 Facade 接口
onTokenUpdate: (token) {
apiConfig.updateToken(token); // 内存(network_sdk)
// TODO: secureStorage.saveToken(token); // 持久化(crypto_sdk)
diff --git a/apps/im_app/lib/features/login/presentation/login_view_model.dart b/apps/im_app/lib/features/login/presentation/login_view_model.dart
index f1de73c..ec4e928 100644
--- a/apps/im_app/lib/features/login/presentation/login_view_model.dart
+++ b/apps/im_app/lib/features/login/presentation/login_view_model.dart
@@ -29,7 +29,7 @@ part 'login_view_model.g.dart';
/// loginViewModelProvider ← @riverpod 自动生成(本文件)
/// → ref.read(loginUseCaseProvider) ← di/ 手动装配
/// → ref.read(authRepositoryProvider) ← di/ 手动装配
-/// → ref.read(apiClientProvider) ← app/di/ 手动装配
+/// → ref.read(networkSdkApiProvider) ← app/di/ 手动装配
/// ```
///
/// ## 数据流位置
@@ -56,6 +56,7 @@ class LoginViewModel extends _$LoginViewModel {
/// 正式 [login] 成功后同样需要调用 [AuthNotifier.login] 更新守卫状态。
Future demoLogin() async {
final storageApi = ref.read(storageSdkProvider);
+
///TODO: StorageSDKLifeCycle 需要只在主项目暴露
final storageLifeCycle = storageApi as StorageSdkLifecycle;
ref.read(authNotifierProvider).login();
@@ -76,10 +77,9 @@ class LoginViewModel extends _$LoginViewModel {
state = state.copyWith(isLoading: true, error: null);
try {
- final user = await ref.read(loginUseCaseProvider).execute(
- email: email,
- password: password,
- );
+ final user = await ref
+ .read(loginUseCaseProvider)
+ .execute(email: email, password: password);
state = state.copyWith(user: user, isLoading: false);
} on FormatException catch (e) {
diff --git a/apps/im_app/macos/Podfile b/apps/im_app/macos/Podfile
index a46f7f2..6fd2b20 100644
--- a/apps/im_app/macos/Podfile
+++ b/apps/im_app/macos/Podfile
@@ -1,4 +1,4 @@
-platform :osx, '11.0'
+platform :osx, '14.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -38,5 +38,8 @@ end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_macos_build_settings(target)
+ target.build_configurations.each do |config|
+ config.build_settings['SWIFT_VERSION'] ||= '6.2'
+ end
end
end
diff --git a/apps/im_app/macos/Runner.xcodeproj/project.pbxproj b/apps/im_app/macos/Runner.xcodeproj/project.pbxproj
index fecd0db..3208875 100644
--- a/apps/im_app/macos/Runner.xcodeproj/project.pbxproj
+++ b/apps/im_app/macos/Runner.xcodeproj/project.pbxproj
@@ -481,7 +481,7 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.2;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/im_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/im_app";
};
name = Debug;
@@ -496,7 +496,7 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.2;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/im_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/im_app";
};
name = Release;
@@ -511,7 +511,7 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.2;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/im_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/im_app";
};
name = Profile;
@@ -557,7 +557,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- MACOSX_DEPLOYMENT_TARGET = 11.0;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
@@ -580,7 +580,7 @@
"@executable_path/../Frameworks",
);
PROVISIONING_PROFILE_SPECIFIER = "";
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.2;
};
name = Profile;
};
@@ -639,7 +639,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- MACOSX_DEPLOYMENT_TARGET = 11.0;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
@@ -689,7 +689,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- MACOSX_DEPLOYMENT_TARGET = 11.0;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
@@ -713,7 +713,7 @@
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.2;
};
name = Debug;
};
@@ -732,7 +732,7 @@
"@executable_path/../Frameworks",
);
PROVISIONING_PROFILE_SPECIFIER = "";
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.2;
};
name = Release;
};
diff --git a/apps/im_app/pubspec.yaml b/apps/im_app/pubspec.yaml
index e02f3fb..b20f848 100644
--- a/apps/im_app/pubspec.yaml
+++ b/apps/im_app/pubspec.yaml
@@ -37,9 +37,13 @@ dependencies:
# 网络状态监听
connectivity_plus: ^6.1.0
+ # JWT 解析(token 过期检测、主动刷新)
+ dart_jsonwebtoken: ^3.3.2
+
# 数据库(schema 定义在 im_app,连接/CRUD 封装在 storage_sdk)
drift: ^2.22.0
+
dev_dependencies:
flutter_test:
sdk: flutter
diff --git a/packages/cipher_guard_sdk/android/build.gradle b/packages/cipher_guard_sdk/android/build.gradle
deleted file mode 100644
index 60bf876..0000000
--- a/packages/cipher_guard_sdk/android/build.gradle
+++ /dev/null
@@ -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
- }
- }
- }
-}
diff --git a/packages/cipher_guard_sdk/android/build.gradle.kts b/packages/cipher_guard_sdk/android/build.gradle.kts
new file mode 100644
index 0000000..9360f2b
--- /dev/null
+++ b/packages/cipher_guard_sdk/android/build.gradle.kts
@@ -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")
+}
diff --git a/packages/cipher_guard_sdk/ios/Classes/CipherGuardSdkPlugin.swift b/packages/cipher_guard_sdk/ios/Classes/CipherGuardSdkPlugin.swift
index 4cfabae..51614bd 100644
--- a/packages/cipher_guard_sdk/ios/Classes/CipherGuardSdkPlugin.swift
+++ b/packages/cipher_guard_sdk/ios/Classes/CipherGuardSdkPlugin.swift
@@ -1,4 +1,4 @@
-import Flutter
+@preconcurrency import Flutter
import UIKit
public class CipherGuardSdkPlugin: NSObject, FlutterPlugin {
diff --git a/packages/cipher_guard_sdk/ios/cipher_guard_sdk.podspec b/packages/cipher_guard_sdk/ios/cipher_guard_sdk.podspec
index 8f648dd..a44f3dd 100644
--- a/packages/cipher_guard_sdk/ios/cipher_guard_sdk.podspec
+++ b/packages/cipher_guard_sdk/ios/cipher_guard_sdk.podspec
@@ -19,7 +19,7 @@ A new Flutter plugin project.
# Flutter.framework does not contain a i386 slice.
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
- s.swift_version = '5.0'
+ s.swift_version = '6.2'
# If your plugin requires a privacy manifest, for example if it uses any
# required reason APIs, update the PrivacyInfo.xcprivacy file to describe your
diff --git a/packages/cipher_guard_sdk/lib/src/data/datasources/encryption_flutter_service.dart b/packages/cipher_guard_sdk/lib/src/data/datasources/encryption_flutter_service.dart
index ff455ba..481bad3 100644
--- a/packages/cipher_guard_sdk/lib/src/data/datasources/encryption_flutter_service.dart
+++ b/packages/cipher_guard_sdk/lib/src/data/datasources/encryption_flutter_service.dart
@@ -29,23 +29,25 @@ class EncryptionFlutterService {
// Get secure random
final secureRandom = FortunaRandom();
secureRandom.seed(KeyParameter(_generateSecureRandomBytes(32)));
-
+
// Create RSA key generator
final keyGen = RSAKeyGenerator();
- keyGen.init(ParametersWithRandom(
- RSAKeyGeneratorParameters(BigInt.parse('65537'), keySize, 64),
- secureRandom,
- ));
-
+ keyGen.init(
+ ParametersWithRandom(
+ RSAKeyGeneratorParameters(BigInt.parse('65537'), keySize, 64),
+ secureRandom,
+ ),
+ );
+
// Generate key pair
final keyPair = keyGen.generateKeyPair();
- final rsaPublicKey = keyPair.publicKey as RSAPublicKey;
- final rsaPrivateKey = keyPair.privateKey as RSAPrivateKey;
-
+ final rsaPublicKey = keyPair.publicKey;
+ final rsaPrivateKey = keyPair.privateKey;
+
// Export to PEM format
final publicKeyPem = _encodeRSAPublicKey(rsaPublicKey);
final privateKeyPem = _encodeRSAPrivateKey(rsaPrivateKey);
-
+
return RsaKeyPairResult(
publicKey: publicKeyPem,
privateKey: privateKeyPem,
@@ -59,18 +61,18 @@ class EncryptionFlutterService {
String _encodeRSAPublicKey(RSAPublicKey publicKey) {
// Build RSAPublicKeyInfo structure
final topSeq = ASN1Sequence();
-
+
// AlgorithmIdentifier: OID 1.2.840.113549.1.1.1 + NULL
final algoSeq = ASN1Sequence();
algoSeq.add(ASN1ObjectIdentifier([1, 2, 840, 113549, 1, 1, 1])); // RSA
algoSeq.add(ASN1Null());
topSeq.add(algoSeq);
-
+
// RSAPublicKey: modulus + publicExponent
final keySeq = ASN1Sequence();
keySeq.add(ASN1Integer(publicKey.n!));
keySeq.add(ASN1Integer(publicKey.exponent!));
-
+
// BitString wrapping the key (with 0 unused bits prefix)
final keyBytes = keySeq.encodedBytes;
final keyList = List.from(keyBytes);
@@ -86,27 +88,27 @@ class EncryptionFlutterService {
String _encodeRSAPrivateKey(RSAPrivateKey privateKey) {
// Build RSAPrivateKey structure (PKCS#8 format)
final topSeq = ASN1Sequence();
-
+
// Version (0)
topSeq.add(ASN1Integer(BigInt.zero));
-
+
// Modulus
topSeq.add(ASN1Integer(privateKey.n!));
-
+
// Public Exponent
topSeq.add(ASN1Integer(privateKey.exponent!));
-
+
// Private Exponent
topSeq.add(ASN1Integer(privateKey.privateExponent!));
-
+
// Prime P
topSeq.add(ASN1Integer(privateKey.p!));
-
+
// Prime Q
topSeq.add(ASN1Integer(privateKey.q!));
-
+
// (Optional CRT params omitted for simplicity)
-
+
final derBytes = topSeq.encodedBytes;
final base64 = base64Encode(derBytes.toList());
return '-----BEGIN PRIVATE KEY-----\n$base64\n-----END PRIVATE KEY-----';
@@ -122,24 +124,24 @@ class EncryptionFlutterService {
try {
// Generate AES key from MD5(password)
final aesKey = _md5Hash(password);
-
+
// Generate random IV (16 bytes)
final iv = _generateSecureRandomBytes(16);
-
+
// AES encrypt using encrypt package
final secretKey = encrypt_pkg.Key(aesKey);
final encryptor = encrypt_pkg.Encrypter(
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.cbc),
);
-
+
final encrypted = encryptor.encrypt(privateKey, iv: encrypt_pkg.IV(iv));
final encryptedBytes = encrypted.bytes;
-
+
// Combine IV + encrypted data
final combined = Uint8List(iv.length + encryptedBytes.length);
combined.setAll(0, iv);
combined.setAll(iv.length, encryptedBytes);
-
+
return base64Encode(combined);
} catch (e) {
throw Exception('Failed to encrypt private key: $e');
@@ -154,25 +156,25 @@ class EncryptionFlutterService {
try {
// Generate AES key from MD5(password)
final aesKey = _md5Hash(password);
-
+
// Decode Base64
final combined = base64Decode(encryptedPrivateKey);
-
+
// Extract IV and encrypted data
final iv = combined.sublist(0, 16);
final encBytes = combined.sublist(16);
-
+
// AES decrypt
final secretKey = encrypt_pkg.Key(aesKey);
final encryptor = encrypt_pkg.Encrypter(
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.cbc),
);
-
+
final decrypted = encryptor.decrypt(
encrypt_pkg.Encrypted(encBytes),
iv: encrypt_pkg.IV(iv),
);
-
+
return decrypted;
} catch (e) {
throw Exception('Failed to decrypt private key: $e');
@@ -185,11 +187,8 @@ class EncryptionFlutterService {
SessionKeyResult generateSessionKey({int initialRound = 1}) {
final keyBytes = _generateSecureRandomBytes(sessionKeySize);
final key = base64Encode(keyBytes);
-
- return SessionKeyResult(
- key: key,
- round: initialRound,
- );
+
+ return SessionKeyResult(key: key, round: initialRound);
}
/// Encrypt session key with RSA public key
@@ -200,11 +199,11 @@ class EncryptionFlutterService {
try {
// Parse RSA public key
final rsaPublicKey = _parsePublicKey(publicKey);
-
+
// RSA encrypt using PKCS1 padding (like native implementations)
final cipher = PKCS1Encoding(RSAEngine());
cipher.init(true, PublicKeyParameter(rsaPublicKey));
-
+
final encryptedBytes = cipher.process(utf8.encode(sessionKey));
return base64Encode(encryptedBytes);
} catch (e) {
@@ -220,11 +219,11 @@ class EncryptionFlutterService {
try {
// Parse RSA private key
final rsaPrivateKey = _parsePrivateKey(privateKey);
-
+
// RSA decrypt using PKCS1 padding (like native implementations)
final cipher = PKCS1Encoding(RSAEngine());
cipher.init(false, PrivateKeyParameter(rsaPrivateKey));
-
+
final decryptedBytes = cipher.process(base64Decode(encryptedSessionKey));
return utf8.decode(decryptedBytes);
} catch (e) {
@@ -243,30 +242,27 @@ class EncryptionFlutterService {
try {
// Derive key for round
final actualKey = _deriveKeyForRound(sessionKey, round);
-
+
// Generate random IV (16 bytes for CTR)
final iv = _generateSecureRandomBytes(16);
-
+
// AES-CTR encrypt
final secretKey = encrypt_pkg.Key(actualKey);
final encryptor = encrypt_pkg.Encrypter(
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.ctr),
);
-
+
final encrypted = encryptor.encrypt(plaintext, iv: encrypt_pkg.IV(iv));
final encryptedBytes = encrypted.bytes;
-
+
// Combine IV + encrypted data
final combined = Uint8List(iv.length + encryptedBytes.length);
combined.setAll(0, iv);
combined.setAll(iv.length, encryptedBytes);
-
+
final data = base64Encode(combined);
-
- return EncryptedMessageResult(
- round: round,
- data: data,
- );
+
+ return EncryptedMessageResult(round: round, data: data);
} catch (e) {
throw Exception('Failed to encrypt message: $e');
}
@@ -281,25 +277,25 @@ class EncryptionFlutterService {
try {
// Derive key for round
final actualKey = _deriveKeyForRound(sessionKey, round);
-
+
// Decode Base64
final combined = base64Decode(encryptedData);
-
+
// Extract IV and encrypted data
final iv = combined.sublist(0, 16);
final encBytes = combined.sublist(16);
-
+
// AES-CTR decrypt
final secretKey = encrypt_pkg.Key(actualKey);
final encryptor = encrypt_pkg.Encrypter(
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.ctr),
);
-
+
final decrypted = encryptor.decrypt(
encrypt_pkg.Encrypted(encBytes),
iv: encrypt_pkg.IV(iv),
);
-
+
return decrypted;
} catch (e) {
throw Exception('Failed to decrypt message: $e');
@@ -316,36 +312,34 @@ class EncryptionFlutterService {
String? _aesSecret;
/// Decrypt push notification (AES-GCM)
- String decryptPushNotification({
- required String encryptedData,
- }) {
+ String decryptPushNotification({required String encryptedData}) {
try {
final secret = _aesSecret;
if (secret == null) {
throw Exception('AES_SECRET not set');
}
-
+
// Convert hex string to bytes
final secretBytes = _hexStringToBytes(secret);
-
+
// Decode Base64
final combined = base64Decode(encryptedData);
-
+
// Extract IV and encrypted data
final iv = combined.sublist(0, gcmIvLength);
final encBytes = combined.sublist(gcmIvLength);
-
+
// AES-GCM decrypt
final secretKey = encrypt_pkg.Key(secretBytes);
final encryptor = encrypt_pkg.Encrypter(
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.gcm),
);
-
+
final decrypted = encryptor.decrypt(
encrypt_pkg.Encrypted(encBytes),
iv: encrypt_pkg.IV(iv),
);
-
+
return decrypted;
} catch (e) {
throw Exception('Failed to decrypt push notification: $e');
@@ -375,10 +369,10 @@ class EncryptionFlutterService {
Uint8List _deriveKeyForRound(String sessionKey, int targetRound) {
// Base64 decode session key
final keyBytes = base64Decode(sessionKey);
-
+
// Apply MD5 for the round (simplified version)
final hash = md5.convert(keyBytes).bytes as Uint8List;
-
+
return hash;
}
@@ -390,19 +384,19 @@ class EncryptionFlutterService {
.replaceAll('\n', '')
.trim();
final bytes = base64Decode(base64);
-
+
// Parse ASN.1 DER format
final asn1Parser = ASN1Parser(Uint8List.fromList(bytes));
final topLevelSeq = asn1Parser.nextObject() as ASN1Sequence;
-
+
final subjectPublicKeyInfo = topLevelSeq.elements[1] as ASN1BitString;
final keyBytes = subjectPublicKeyInfo.contentBytes();
final keyParser = ASN1Parser(Uint8List.fromList(keyBytes));
final keySeq = keyParser.nextObject() as ASN1Sequence;
-
+
final modulus = keySeq.elements[0] as ASN1Integer;
final publicExponent = keySeq.elements[1] as ASN1Integer;
-
+
return RSAPublicKey(
modulus.valueAsBigInteger,
publicExponent.valueAsBigInteger,
@@ -417,11 +411,11 @@ class EncryptionFlutterService {
.replaceAll('\n', '')
.trim();
final bytes = base64Decode(base64);
-
+
// Parse ASN.1 DER format
final asn1Parser = ASN1Parser(Uint8List.fromList(bytes));
final keySeq = asn1Parser.nextObject() as ASN1Sequence;
-
+
final modulus = keySeq.elements[1] as ASN1Integer;
final privateExponent = keySeq.elements[3] as ASN1Integer;
final p = keySeq.elements[4] as ASN1Integer;
@@ -440,7 +434,9 @@ class EncryptionFlutterService {
final len = hex.length;
final data = Uint8List(len ~/ 2);
for (var i = 0; i < len; i += 2) {
- data[i ~/ 2] = (int.parse(hex[i], radix: 16) << 4) + int.parse(hex[i + 1], radix: 16);
+ data[i ~/ 2] =
+ (int.parse(hex[i], radix: 16) << 4) +
+ int.parse(hex[i + 1], radix: 16);
}
return data;
}
@@ -468,4 +464,3 @@ class EncryptedMessageResult {
EncryptedMessageResult({required this.round, required this.data});
}
-
diff --git a/packages/im_log_sdk/android/build.gradle.kts b/packages/im_log_sdk/android/build.gradle.kts
index 0544edc..7e8555a 100644
--- a/packages/im_log_sdk/android/build.gradle.kts
+++ b/packages/im_log_sdk/android/build.gradle.kts
@@ -7,7 +7,6 @@ buildscript {
google()
mavenCentral()
}
-
dependencies {
classpath("com.android.tools.build:gradle:8.11.1")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
@@ -23,12 +22,11 @@ allprojects {
plugins {
id("com.android.library")
- id("kotlin-android")
+ id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.example.im_log_sdk"
-
compileSdk = 36
compileOptions {
@@ -36,17 +34,9 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
- kotlinOptions {
- jvmTarget = JavaVersion.VERSION_17.toString()
- }
-
sourceSets {
- getByName("main") {
- java.srcDirs("src/main/kotlin")
- }
- getByName("test") {
- java.srcDirs("src/test/kotlin")
- }
+ getByName("main") { java.srcDirs("src/main/kotlin") }
+ getByName("test") { java.srcDirs("src/test/kotlin") }
}
defaultConfig {
@@ -58,9 +48,7 @@ android {
isIncludeAndroidResources = true
all {
it.useJUnitPlatform()
-
it.outputs.upToDateWhen { false }
-
it.testLogging {
events("passed", "skipped", "failed", "standardOut", "standardError")
showStandardStreams = true
@@ -70,6 +58,12 @@ android {
}
}
+kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+ }
+}
+
dependencies {
testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.mockito:mockito-core:5.0.0")
diff --git a/packages/im_log_sdk/ios/Classes/ImLogSdkPlugin.swift b/packages/im_log_sdk/ios/Classes/ImLogSdkPlugin.swift
index 076409e..405c636 100644
--- a/packages/im_log_sdk/ios/Classes/ImLogSdkPlugin.swift
+++ b/packages/im_log_sdk/ios/Classes/ImLogSdkPlugin.swift
@@ -1,4 +1,4 @@
-import Flutter
+@preconcurrency import Flutter
import UIKit
public class ImLogSdkPlugin: NSObject, FlutterPlugin {
diff --git a/packages/im_log_sdk/ios/im_log_sdk.podspec b/packages/im_log_sdk/ios/im_log_sdk.podspec
index f3eb15d..8df33d7 100644
--- a/packages/im_log_sdk/ios/im_log_sdk.podspec
+++ b/packages/im_log_sdk/ios/im_log_sdk.podspec
@@ -19,7 +19,7 @@ A new Flutter plugin project.
# Flutter.framework does not contain a i386 slice.
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
- s.swift_version = '5.0'
+ s.swift_version = '6.2'
# If your plugin requires a privacy manifest, for example if it uses any
# required reason APIs, update the PrivacyInfo.xcprivacy file to describe your
diff --git a/packages/im_log_sdk/macos/Classes/ImLogSdkPlugin.swift b/packages/im_log_sdk/macos/Classes/ImLogSdkPlugin.swift
index 3574fba..b00135b 100644
--- a/packages/im_log_sdk/macos/Classes/ImLogSdkPlugin.swift
+++ b/packages/im_log_sdk/macos/Classes/ImLogSdkPlugin.swift
@@ -1,5 +1,5 @@
import Cocoa
-import FlutterMacOS
+@preconcurrency import FlutterMacOS
public class ImLogSdkPlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
diff --git a/packages/im_log_sdk/macos/im_log_sdk.podspec b/packages/im_log_sdk/macos/im_log_sdk.podspec
index e7b420f..d864ccf 100644
--- a/packages/im_log_sdk/macos/im_log_sdk.podspec
+++ b/packages/im_log_sdk/macos/im_log_sdk.podspec
@@ -24,7 +24,7 @@ A new Flutter plugin project.
s.dependency 'FlutterMacOS'
- s.platform = :osx, '10.11'
+ s.platform = :osx, '14.0'
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
- s.swift_version = '5.0'
+ s.swift_version = '6.2'
end
diff --git a/packages/l10n_sdk/android/build.gradle b/packages/l10n_sdk/android/build.gradle
deleted file mode 100644
index 2de5754..0000000
--- a/packages/l10n_sdk/android/build.gradle
+++ /dev/null
@@ -1,66 +0,0 @@
-group = "com.example.l10n_sdk"
-version = "1.0-SNAPSHOT"
-
-buildscript {
- ext.kotlin_version = "2.2.20"
- repositories {
- google()
- mavenCentral()
- }
-
- dependencies {
- classpath("com.android.tools.build:gradle:8.11.1")
- classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
- }
-}
-
-allprojects {
- repositories {
- google()
- mavenCentral()
- }
-}
-
-apply plugin: "com.android.library"
-apply plugin: "kotlin-android"
-
-android {
- namespace = "com.example.l10n_sdk"
-
- compileSdk = 36
-
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_17
- targetCompatibility = JavaVersion.VERSION_17
- }
-
- kotlinOptions {
- jvmTarget = JavaVersion.VERSION_17
- }
-
- sourceSets {
- main.java.srcDirs += "src/main/kotlin"
- test.java.srcDirs += "src/test/kotlin"
- }
-
- defaultConfig {
- minSdk = 24
- }
-
- dependencies {
- testImplementation("org.jetbrains.kotlin:kotlin-test")
- testImplementation("org.mockito:mockito-core:5.0.0")
- }
-
- testOptions {
- unitTests.all {
- useJUnitPlatform()
-
- testLogging {
- events "passed", "skipped", "failed", "standardOut", "standardError"
- outputs.upToDateWhen {false}
- showStandardStreams = true
- }
- }
- }
-}
diff --git a/packages/l10n_sdk/android/build.gradle.kts b/packages/l10n_sdk/android/build.gradle.kts
new file mode 100644
index 0000000..e4081dd
--- /dev/null
+++ b/packages/l10n_sdk/android/build.gradle.kts
@@ -0,0 +1,71 @@
+group = "com.example.l10n_sdk"
+version = "1.0-SNAPSHOT"
+
+buildscript {
+ val kotlinVersion = "2.2.20"
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath("com.android.tools.build:gradle:8.11.1")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+plugins {
+ id("com.android.library")
+ id("org.jetbrains.kotlin.android")
+}
+
+android {
+ namespace = "com.example.l10n_sdk"
+ compileSdk = 36
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ sourceSets {
+ getByName("main") { java.srcDirs("src/main/kotlin") }
+ getByName("test") { java.srcDirs("src/test/kotlin") }
+ }
+
+ defaultConfig {
+ minSdk = 24
+ }
+
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ all {
+ it.useJUnitPlatform()
+ it.outputs.upToDateWhen { false }
+ it.testLogging {
+ events("passed", "skipped", "failed", "standardOut", "standardError")
+ showStandardStreams = true
+ }
+ }
+ }
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+ }
+}
+
+dependencies {
+ // Compose BOM 统一管理子库版本,按需在各 SDK 中引入具体组件
+ testImplementation("org.jetbrains.kotlin:kotlin-test")
+ testImplementation("org.mockito:mockito-core:5.0.0")
+}
diff --git a/packages/l10n_sdk/ios/Classes/L10nSdkPlugin.swift b/packages/l10n_sdk/ios/Classes/L10nSdkPlugin.swift
index 0c80a7c..eabc282 100644
--- a/packages/l10n_sdk/ios/Classes/L10nSdkPlugin.swift
+++ b/packages/l10n_sdk/ios/Classes/L10nSdkPlugin.swift
@@ -1,4 +1,4 @@
-import Flutter
+@preconcurrency import Flutter
import UIKit
public class L10nSdkPlugin: NSObject, FlutterPlugin {
diff --git a/packages/l10n_sdk/ios/l10n_sdk.podspec b/packages/l10n_sdk/ios/l10n_sdk.podspec
index a297469..9a1c11a 100644
--- a/packages/l10n_sdk/ios/l10n_sdk.podspec
+++ b/packages/l10n_sdk/ios/l10n_sdk.podspec
@@ -19,7 +19,7 @@ A new Flutter plugin project.
# Flutter.framework does not contain a i386 slice.
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
- s.swift_version = '5.0'
+ s.swift_version = '6.2'
# If your plugin requires a privacy manifest, for example if it uses any
# required reason APIs, update the PrivacyInfo.xcprivacy file to describe your
diff --git a/packages/media_sdk/android/build.gradle b/packages/media_sdk/android/build.gradle
deleted file mode 100644
index 0ef8774..0000000
--- a/packages/media_sdk/android/build.gradle
+++ /dev/null
@@ -1,66 +0,0 @@
-group = "com.example.media_sdk"
-version = "1.0-SNAPSHOT"
-
-buildscript {
- ext.kotlin_version = "2.2.20"
- repositories {
- google()
- mavenCentral()
- }
-
- dependencies {
- classpath("com.android.tools.build:gradle:8.11.1")
- classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
- }
-}
-
-allprojects {
- repositories {
- google()
- mavenCentral()
- }
-}
-
-apply plugin: "com.android.library"
-apply plugin: "kotlin-android"
-
-android {
- namespace = "com.example.media_sdk"
-
- compileSdk = 36
-
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_17
- targetCompatibility = JavaVersion.VERSION_17
- }
-
- kotlinOptions {
- jvmTarget = JavaVersion.VERSION_17
- }
-
- sourceSets {
- main.java.srcDirs += "src/main/kotlin"
- test.java.srcDirs += "src/test/kotlin"
- }
-
- defaultConfig {
- minSdk = 24
- }
-
- dependencies {
- testImplementation("org.jetbrains.kotlin:kotlin-test")
- testImplementation("org.mockito:mockito-core:5.0.0")
- }
-
- testOptions {
- unitTests.all {
- useJUnitPlatform()
-
- testLogging {
- events "passed", "skipped", "failed", "standardOut", "standardError"
- outputs.upToDateWhen {false}
- showStandardStreams = true
- }
- }
- }
-}
diff --git a/packages/media_sdk/android/build.gradle.kts b/packages/media_sdk/android/build.gradle.kts
new file mode 100644
index 0000000..fe6f889
--- /dev/null
+++ b/packages/media_sdk/android/build.gradle.kts
@@ -0,0 +1,71 @@
+group = "com.example.media_sdk"
+version = "1.0-SNAPSHOT"
+
+buildscript {
+ val kotlinVersion = "2.2.20"
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath("com.android.tools.build:gradle:8.11.1")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+plugins {
+ id("com.android.library")
+ id("org.jetbrains.kotlin.android")
+}
+
+android {
+ namespace = "com.example.media_sdk"
+ compileSdk = 36
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ sourceSets {
+ getByName("main") { java.srcDirs("src/main/kotlin") }
+ getByName("test") { java.srcDirs("src/test/kotlin") }
+ }
+
+ defaultConfig {
+ minSdk = 24
+ }
+
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ all {
+ it.useJUnitPlatform()
+ it.outputs.upToDateWhen { false }
+ it.testLogging {
+ events("passed", "skipped", "failed", "standardOut", "standardError")
+ showStandardStreams = true
+ }
+ }
+ }
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+ }
+}
+
+dependencies {
+ // Compose BOM 统一管理子库版本,按需在各 SDK 中引入具体组件
+ testImplementation("org.jetbrains.kotlin:kotlin-test")
+ testImplementation("org.mockito:mockito-core:5.0.0")
+}
diff --git a/packages/media_sdk/ios/Classes/MediaSdkPlugin.swift b/packages/media_sdk/ios/Classes/MediaSdkPlugin.swift
index d9b652d..49131a0 100644
--- a/packages/media_sdk/ios/Classes/MediaSdkPlugin.swift
+++ b/packages/media_sdk/ios/Classes/MediaSdkPlugin.swift
@@ -1,4 +1,4 @@
-import Flutter
+@preconcurrency import Flutter
import UIKit
public class MediaSdkPlugin: NSObject, FlutterPlugin {
diff --git a/packages/media_sdk/ios/media_sdk.podspec b/packages/media_sdk/ios/media_sdk.podspec
index 9125e0f..6a1e285 100644
--- a/packages/media_sdk/ios/media_sdk.podspec
+++ b/packages/media_sdk/ios/media_sdk.podspec
@@ -19,7 +19,7 @@ A new Flutter plugin project.
# Flutter.framework does not contain a i386 slice.
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
- s.swift_version = '5.0'
+ s.swift_version = '6.2'
# If your plugin requires a privacy manifest, for example if it uses any
# required reason APIs, update the PrivacyInfo.xcprivacy file to describe your
diff --git a/packages/networks_sdk/android/build.gradle b/packages/networks_sdk/android/build.gradle
deleted file mode 100644
index 6746089..0000000
--- a/packages/networks_sdk/android/build.gradle
+++ /dev/null
@@ -1,66 +0,0 @@
-group = "com.example.networks_sdk"
-version = "1.0-SNAPSHOT"
-
-buildscript {
- ext.kotlin_version = "2.2.20"
- repositories {
- google()
- mavenCentral()
- }
-
- dependencies {
- classpath("com.android.tools.build:gradle:8.11.1")
- classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
- }
-}
-
-allprojects {
- repositories {
- google()
- mavenCentral()
- }
-}
-
-apply plugin: "com.android.library"
-apply plugin: "kotlin-android"
-
-android {
- namespace = "com.example.networks_sdk"
-
- compileSdk = 36
-
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_17
- targetCompatibility = JavaVersion.VERSION_17
- }
-
- kotlinOptions {
- jvmTarget = JavaVersion.VERSION_17
- }
-
- sourceSets {
- main.java.srcDirs += "src/main/kotlin"
- test.java.srcDirs += "src/test/kotlin"
- }
-
- defaultConfig {
- minSdk = 24
- }
-
- dependencies {
- testImplementation("org.jetbrains.kotlin:kotlin-test")
- testImplementation("org.mockito:mockito-core:5.0.0")
- }
-
- testOptions {
- unitTests.all {
- useJUnitPlatform()
-
- testLogging {
- events "passed", "skipped", "failed", "standardOut", "standardError"
- outputs.upToDateWhen {false}
- showStandardStreams = true
- }
- }
- }
-}
diff --git a/packages/networks_sdk/android/build.gradle.kts b/packages/networks_sdk/android/build.gradle.kts
new file mode 100644
index 0000000..bcfdb84
--- /dev/null
+++ b/packages/networks_sdk/android/build.gradle.kts
@@ -0,0 +1,71 @@
+group = "com.example.networks_sdk"
+version = "1.0-SNAPSHOT"
+
+buildscript {
+ val kotlinVersion = "2.2.20"
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath("com.android.tools.build:gradle:8.11.1")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+plugins {
+ id("com.android.library")
+ id("org.jetbrains.kotlin.android")
+}
+
+android {
+ namespace = "com.example.networks_sdk"
+ compileSdk = 36
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ sourceSets {
+ getByName("main") { java.srcDirs("src/main/kotlin") }
+ getByName("test") { java.srcDirs("src/test/kotlin") }
+ }
+
+ defaultConfig {
+ minSdk = 24
+ }
+
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ all {
+ it.useJUnitPlatform()
+ it.outputs.upToDateWhen { false }
+ it.testLogging {
+ events("passed", "skipped", "failed", "standardOut", "standardError")
+ showStandardStreams = true
+ }
+ }
+ }
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+ }
+}
+
+dependencies {
+ // Compose BOM 统一管理子库版本,按需在各 SDK 中引入具体组件
+ testImplementation("org.jetbrains.kotlin:kotlin-test")
+ testImplementation("org.mockito:mockito-core:5.0.0")
+}
diff --git a/packages/networks_sdk/ios/Classes/NetworksSdkPlugin.swift b/packages/networks_sdk/ios/Classes/NetworksSdkPlugin.swift
index f534410..9c3a439 100644
--- a/packages/networks_sdk/ios/Classes/NetworksSdkPlugin.swift
+++ b/packages/networks_sdk/ios/Classes/NetworksSdkPlugin.swift
@@ -1,4 +1,4 @@
-import Flutter
+@preconcurrency import Flutter
import UIKit
public class NetworksSdkPlugin: NSObject, FlutterPlugin {
diff --git a/packages/networks_sdk/ios/networks_sdk.podspec b/packages/networks_sdk/ios/networks_sdk.podspec
index a874b54..245e3c4 100644
--- a/packages/networks_sdk/ios/networks_sdk.podspec
+++ b/packages/networks_sdk/ios/networks_sdk.podspec
@@ -19,7 +19,7 @@ A new Flutter plugin project.
# Flutter.framework does not contain a i386 slice.
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
- s.swift_version = '5.0'
+ s.swift_version = '6.2'
# If your plugin requires a privacy manifest, for example if it uses any
# required reason APIs, update the PrivacyInfo.xcprivacy file to describe your
diff --git a/packages/networks_sdk/lib/networks_sdk.dart b/packages/networks_sdk/lib/networks_sdk.dart
index 3f26639..5e2405a 100644
--- a/packages/networks_sdk/lib/networks_sdk.dart
+++ b/packages/networks_sdk/lib/networks_sdk.dart
@@ -6,8 +6,9 @@ export 'src/presentation/facade/networks_messaging_api.dart';
// Wiring - Implementations
export 'src/presentation/wiring/networks_messaging_api_impl.dart';
-// Dio 类型重导出(App 层上传 / override decodeResponse 需要,避免直接依赖 dio)
-export 'package:dio/dio.dart' show FormData, MultipartFile, Response;
+// Dio 类型重导出(App 层上传 / CancelToken / override decodeResponse 需要,避免直接依赖 dio)
+export 'package:dio/dio.dart'
+ show FormData, MultipartFile, Response, CancelToken;
// Config
export 'src/presentation/wiring/api_config.dart';
@@ -18,6 +19,7 @@ export 'src/presentation/wiring/network_callbacks.dart';
export 'src/data/dto/api_requestable.dart';
export 'src/data/dto/api_response_wrapper.dart';
export 'src/domain/entities/api_error.dart';
+export 'src/domain/entities/encrypted_request.dart';
export 'src/domain/entities/http_method.dart';
export 'src/domain/entities/api_request_type.dart';
diff --git a/packages/networks_sdk/lib/src/data/datasources/http/api_client.dart b/packages/networks_sdk/lib/src/data/datasources/http/api_client.dart
index fc90fdd..460934d 100644
--- a/packages/networks_sdk/lib/src/data/datasources/http/api_client.dart
+++ b/packages/networks_sdk/lib/src/data/datasources/http/api_client.dart
@@ -1,12 +1,15 @@
import 'package:dio/dio.dart';
import 'package:networks_sdk/src/data/datasources/http/interceptor/auth_interceptor.dart';
+import 'package:networks_sdk/src/data/datasources/http/interceptor/encryption_interceptor.dart';
import 'package:networks_sdk/src/data/datasources/http/interceptor/logging_interceptor.dart';
import 'package:networks_sdk/src/data/datasources/http/interceptor/retry_interceptor.dart';
import 'package:networks_sdk/src/domain/entities/api_error.dart';
import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
/// REST API 客户端
-/// 基于 Dio,提供 `executeRequest` 唯一入口
+/// 基于 Dio,提供请求执行入口
+///
+/// 拦截器链顺序:Auth → Encryption → 自定义 → Retry → Logging
///
/// 使用方式:
/// ```dart
@@ -28,9 +31,10 @@ class ApiClient {
receiveTimeout: const Duration(seconds: 60),
);
- // 挂载拦截器(顺序:Auth → 自定义 → Retry → Logging)
+ // 挂载拦截器(顺序:Auth → Encryption → 自定义 → Retry → Logging)
_dio.interceptors.addAll([
AuthInterceptor(config),
+ EncryptionInterceptor(config),
if (additionalInterceptors != null) ...additionalInterceptors,
RetryInterceptor(config: config, dio: _dio),
LoggingInterceptor(onLog: config.onLog),
@@ -49,16 +53,16 @@ class ApiClient {
return const ApiError.timeout();
case DioExceptionType.connectionError:
return const ApiError.noNetworkConnection();
+ case DioExceptionType.cancel:
+ return const ApiError.cancelled();
default:
if (e.response != null) {
return ApiError.apiError(
code: e.response!.statusCode ?? 0,
- message: e.response!.statusMessage ??
- e.message ??
- 'Request failed',
+ message: e.response!.statusMessage ?? e.message ?? 'Request failed',
);
}
return ApiError.networkError(e.message ?? 'Network error');
}
}
-}
\ No newline at end of file
+}
diff --git a/packages/networks_sdk/lib/src/data/datasources/http/interceptor/encryption_interceptor.dart b/packages/networks_sdk/lib/src/data/datasources/http/interceptor/encryption_interceptor.dart
new file mode 100644
index 0000000..92c977f
--- /dev/null
+++ b/packages/networks_sdk/lib/src/data/datasources/http/interceptor/encryption_interceptor.dart
@@ -0,0 +1,114 @@
+import 'package:dio/dio.dart';
+import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
+
+/// 加密拦截器(预留给 cipher_guard_sdk)
+///
+/// 在拦截器链中位于 Auth 之后、Retry 之前:
+/// `Auth → Encryption → Custom → Retry → Logging`
+///
+/// 回调为 null 时自动跳过,不影响正常请求流程。
+/// 后续 cipher_guard_sdk 接入后,App 层在 ApiConfig 中注入
+/// `onEncryptRequest` / `onDecryptResponse` 即可启用加密。
+///
+/// ## 加密能力
+///
+/// 与简单的 body 加解密不同,本拦截器支持完整的请求改写:
+/// - 路径加密(如 `/api/login` → `/api/hex(encrypt(login))`)
+/// - 请求体加密(Map → base64 字符串)
+/// - Header 注入(X-Token、X-Signature、secret-key 等)
+/// - Content-Type 覆盖(application/json → text/plain)
+///
+/// 加密回调接收原始 path、headers、body,返回 [EncryptedRequest],
+/// 拦截器根据非 null 字段覆盖请求。
+class EncryptionInterceptor extends Interceptor {
+ final ApiConfig _config;
+
+ EncryptionInterceptor(this._config);
+
+ @override
+ void onRequest(
+ RequestOptions options,
+ RequestInterceptorHandler handler,
+ ) async {
+ final encrypt = _config.onEncryptRequest;
+ if (encrypt == null) {
+ handler.next(options);
+ return;
+ }
+
+ try {
+ // 收集当前 headers(转为 Map)
+ final currentHeaders = {};
+ options.headers.forEach((key, value) {
+ if (value != null) currentHeaders[key] = value.toString();
+ });
+
+ final result = await encrypt(options.path, currentHeaders, options.data);
+
+ // 根据非 null 字段覆盖请求
+ if (result.path != null) {
+ options.path = result.path!;
+ }
+ if (result.body != null) {
+ options.data = result.body;
+ }
+ if (result.headers != null) {
+ options.headers.addAll(result.headers!);
+ }
+ if (result.contentType != null) {
+ options.contentType = result.contentType;
+ }
+
+ _config.onLog?.call(
+ 'Request encrypted: ${options.path}',
+ tag: 'Encryption',
+ );
+
+ handler.next(options);
+ } catch (e) {
+ _config.onLog?.call('Request encryption failed: $e', tag: 'Encryption');
+ handler.reject(
+ DioException(
+ requestOptions: options,
+ message: 'Request encryption failed: $e',
+ ),
+ );
+ }
+ }
+
+ @override
+ void onResponse(Response response, ResponseInterceptorHandler handler) async {
+ final decrypt = _config.onDecryptResponse;
+ if (decrypt == null) {
+ handler.next(response);
+ return;
+ }
+
+ // 跳过 null 响应
+ if (response.data == null) {
+ handler.next(response);
+ return;
+ }
+
+ try {
+ final decrypted = await decrypt(response.data as Object);
+ response.data = decrypted;
+
+ _config.onLog?.call(
+ 'Response decrypted: ${response.requestOptions.path}',
+ tag: 'Encryption',
+ );
+
+ handler.next(response);
+ } catch (e) {
+ _config.onLog?.call('Response decryption failed: $e', tag: 'Encryption');
+ handler.reject(
+ DioException(
+ requestOptions: response.requestOptions,
+ response: response,
+ message: 'Response decryption failed: $e',
+ ),
+ );
+ }
+ }
+}
diff --git a/packages/networks_sdk/lib/src/data/datasources/http/interceptor/retry_interceptor.dart b/packages/networks_sdk/lib/src/data/datasources/http/interceptor/retry_interceptor.dart
index 6864329..3cb5490 100644
--- a/packages/networks_sdk/lib/src/data/datasources/http/interceptor/retry_interceptor.dart
+++ b/packages/networks_sdk/lib/src/data/datasources/http/interceptor/retry_interceptor.dart
@@ -1,35 +1,41 @@
-import 'dart:async';
import 'dart:math';
import 'package:dio/dio.dart';
+import 'package:networks_sdk/src/data/datasources/http/token_refresh_manager.dart';
import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
-
/// 重试拦截器
///
/// 两层重试机制:
///
/// 1. **Token 刷新重试**(onResponse)
-/// 检测 Token 过期响应 → 触发刷新回调 → 用新 Token 重试原请求
+/// 检测 Token 过期响应 → 触发 [TokenRefreshManager] → 用新 Token 重试原请求
///
/// 2. **瞬态错误重试**(onError)
/// 5xx / 超时 / 连接失败 → 指数退避 + jitter → 自动重试
/// 由 [ApiConfig.maxRetries] 控制(默认 0 = 不启用)
///
+/// 另外在 onResponse 中处理强制登出码和业务错误码。
+///
/// 两层独立运作,可叠加。
class RetryInterceptor extends Interceptor {
final ApiConfig config;
final Dio dio;
-
- /// Token 刷新锁(防止多个请求同时刷新)
- bool _isRefreshing = false;
- Completer? _refreshCompleter;
+ final TokenRefreshManager _tokenManager;
final _random = Random();
- RetryInterceptor({required this.config, required this.dio});
+ RetryInterceptor({required this.config, required this.dio})
+ : _tokenManager = TokenRefreshManager(
+ onTokenRefresh: config.onTokenRefresh,
+ onLog: config.onLog,
+ timeout: config.tokenRefreshTimeout,
+ reuseWindow: config.tokenReuseWindow,
+ onGetTokenExpiry: config.onGetTokenExpiry,
+ proactiveRefreshThreshold: config.proactiveRefreshThreshold,
+ );
- // ── Token 刷新重试 ────────────────────────────────────────────────────────
+ // ── 响应处理(Token 过期 / 强制登出 / 业务错误码)──────────────────────
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
@@ -40,13 +46,12 @@ class RetryInterceptor extends Interceptor {
final data = response.data as Map;
final code = _parseCode(data['code']);
+ final message = data['message'] as String? ?? '';
+ final requestPath = response.requestOptions.path;
// 检查强制登出
if (config.forceLogoutCodes.contains(code)) {
- config.onLog?.call(
- 'Force logout detected (code: $code)',
- tag: 'Network',
- );
+ config.onLog?.call('Force logout detected (code: $code)', tag: 'Network');
config.onForceLogout?.call();
handler.reject(
DioException(
@@ -58,8 +63,9 @@ class RetryInterceptor extends Interceptor {
return;
}
- // 检查 Token 过期
- if (config.tokenExpiredCodes.contains(code)) {
+ // 检查 Token 过期(跳过已标记为 token 重试的请求,防止递归)
+ if (config.tokenExpiredCodes.contains(code) &&
+ response.requestOptions.extra['_isTokenRetry'] != true) {
config.onLog?.call(
'Token expired (code: $code), refreshing...',
tag: 'Network',
@@ -68,17 +74,27 @@ class RetryInterceptor extends Interceptor {
return;
}
+ // 业务错误码拦截:非 0 且不在特殊码集合中
+ if (code != 0 && config.onBusinessError != null) {
+ final handled = config.onBusinessError!(code, message, requestPath);
+ if (handled) {
+ // App 层已处理,正常传递响应
+ handler.next(response);
+ return;
+ }
+ }
+
handler.next(response);
}
/// 处理 Token 过期:刷新 + 重试
Future _handleTokenExpired(
- Response response,
- ResponseInterceptorHandler handler,
- ) async {
- final refreshSuccess = await _refreshToken();
+ Response response,
+ ResponseInterceptorHandler handler,
+ ) async {
+ final newToken = await _tokenManager.refreshIfNeeded();
- if (!refreshSuccess) {
+ if (newToken == null) {
config.onLog?.call('Token refresh failed', tag: 'Network');
config.onForceLogout?.call();
handler.reject(
@@ -91,12 +107,14 @@ class RetryInterceptor extends Interceptor {
return;
}
- // 刷新成功,用新 token 重试原请求
+ // 刷新成功,更新 config 并用新 token 重试原请求
+ config.updateToken(newToken);
config.onLog?.call('Token refreshed, retrying...', tag: 'Network');
try {
final options = response.requestOptions;
- // 更新 header 中的 token
- options.headers['token'] = config.token;
+ options.headers['token'] = newToken;
+ // 标记为 token 重试请求,防止重试后再次进入 _handleTokenExpired 造成递归
+ options.extra['_isTokenRetry'] = true;
final retryResponse = await dio.fetch(options);
handler.resolve(retryResponse);
@@ -105,41 +123,6 @@ class RetryInterceptor extends Interceptor {
}
}
- /// Token 刷新(串行锁)
- /// 多个请求同时过期时,只刷新一次,其余等待
- Future _refreshToken() async {
- if (_isRefreshing) {
- // 等待正在进行的刷新
- return _refreshCompleter?.future ?? Future.value(false);
- }
-
- _isRefreshing = true;
- _refreshCompleter = Completer();
-
- try {
- if (config.onTokenRefresh == null) {
- _refreshCompleter!.complete(false);
- return false;
- }
-
- final newToken = await config.onTokenRefresh!();
- final success = newToken != null;
-
- if (success) {
- config.updateToken(newToken);
- }
-
- _refreshCompleter!.complete(success);
- return success;
- } catch (e) {
- _refreshCompleter!.complete(false);
- return false;
- } finally {
- _isRefreshing = false;
- _refreshCompleter = null;
- }
- }
-
// ── 瞬态错误重试(指数退避 + jitter)────────────────────────────────────
@override
@@ -162,7 +145,7 @@ class RetryInterceptor extends Interceptor {
final delayMs = _backoffDelay(attempt);
config.onLog?.call(
'Transient error, retry ${attempt + 1}/${config.maxRetries} '
- 'in ${delayMs}ms: ${options.path}',
+ 'in ${delayMs}ms: ${options.path}',
tag: 'Retry',
);
@@ -184,7 +167,7 @@ class RetryInterceptor extends Interceptor {
case DioExceptionType.connectionError:
return true;
case DioExceptionType.badResponse:
- // 5xx 服务端错误可重试
+ // 5xx 服务端错误可重试
final statusCode = err.response?.statusCode;
return statusCode != null && statusCode >= 500;
default:
@@ -198,7 +181,9 @@ class RetryInterceptor extends Interceptor {
int _backoffDelay(int attempt) {
final baseMs = config.retryBaseDelay.inMilliseconds;
final exponentialMs = min(baseMs * pow(2, attempt).toInt(), 30000);
- final jitterMs = _random.nextInt((exponentialMs * 0.25).toInt().clamp(1, 7500));
+ final jitterMs = _random.nextInt(
+ (exponentialMs * 0.25).toInt().clamp(1, 7500),
+ );
return exponentialMs + jitterMs;
}
diff --git a/packages/networks_sdk/lib/src/data/datasources/http/token_refresh_manager.dart b/packages/networks_sdk/lib/src/data/datasources/http/token_refresh_manager.dart
new file mode 100644
index 0000000..96bee72
--- /dev/null
+++ b/packages/networks_sdk/lib/src/data/datasources/http/token_refresh_manager.dart
@@ -0,0 +1,152 @@
+import 'dart:async';
+
+import 'package:networks_sdk/src/presentation/wiring/network_callbacks.dart';
+
+/// Token 刷新管理器
+///
+/// 两种刷新模式:
+///
+/// 1. **被动刷新**([refreshIfNeeded])— 拦截器检测到 token 过期后调用
+/// 2. **主动刷新**([proactivelyRefreshIfNeeded])— 解析 JWT exp,
+/// 距过期不足阈值时提前刷新,避免带过期 token 发请求
+///
+/// 两种模式共享串行锁和时间窗口保护:
+/// - **串行锁** — 同一时刻只执行一次刷新,其余请求等待同一 Completer
+/// - **时间窗口** — 刷新成功后 [reuseWindow] 内再次调用直接返回缓存 token
+/// - **超时保护** — 刷新回调超过 [timeout] 自动失败,防止死锁
+class TokenRefreshManager {
+ final OnTokenRefresh? onTokenRefresh;
+ final OnLog? onLog;
+
+ /// 刷新超时时间(防止 onTokenRefresh 卡住导致所有请求阻塞)
+ final Duration timeout;
+
+ /// 时间窗口:刷新成功后此时间内再次调用直接返回缓存 token
+ final Duration reuseWindow;
+
+ /// Token 过期时间解析(App 层注入 JWT exp 解析逻辑)
+ final OnGetTokenExpiry? onGetTokenExpiry;
+
+ /// 主动刷新阈值:距过期不足此时间时提前刷新(默认 1 小时)
+ final Duration proactiveRefreshThreshold;
+
+ /// 当前正在进行的刷新任务(null = 空闲)
+ Completer? _completer;
+
+ /// 上次刷新成功的时间戳
+ DateTime? _lastRefreshTime;
+
+ /// 上次刷新成功的 token(时间窗口内复用)
+ String? _lastToken;
+
+ TokenRefreshManager({
+ this.onTokenRefresh,
+ this.onLog,
+ this.timeout = const Duration(seconds: 10),
+ this.reuseWindow = const Duration(seconds: 3),
+ this.onGetTokenExpiry,
+ this.proactiveRefreshThreshold = const Duration(hours: 1),
+ });
+
+ /// 执行 token 刷新(如果需要)
+ ///
+ /// 返回新 token(刷新成功或在时间窗口内),
+ /// 返回 null = 刷新失败或超时。
+ Future refreshIfNeeded() async {
+ // 1. 时间窗口:最近刷新过且未超时 → 直接返回缓存的 token
+ if (_isWithinReuseWindow()) {
+ _log('Token refreshed recently, reusing');
+ return _lastToken;
+ }
+
+ // 2. 有正在进行的刷新 → 等待同一 Completer
+ final existing = _completer;
+ if (existing != null) {
+ _log('Waiting for ongoing token refresh');
+ return existing.future;
+ }
+
+ // 3. 发起新的刷新
+ if (onTokenRefresh == null) {
+ _log('No onTokenRefresh callback configured');
+ return null;
+ }
+
+ final completer = Completer();
+ _completer = completer;
+
+ try {
+ final newToken = await onTokenRefresh!().timeout(
+ timeout,
+ onTimeout: () {
+ _log('Token refresh timed out after ${timeout.inSeconds}s');
+ return null;
+ },
+ );
+
+ final success = newToken != null && newToken.isNotEmpty;
+
+ if (success) {
+ _lastRefreshTime = DateTime.now();
+ _lastToken = newToken;
+ _log('Token refreshed successfully');
+ } else {
+ _log('Token refresh failed (null or empty token)');
+ }
+
+ // 先 complete 再清引用,确保等待者能拿到结果
+ completer.complete(success ? newToken : null);
+ return success ? newToken : null;
+ } catch (e) {
+ _log('Token refresh error: $e');
+ completer.complete(null);
+ return null;
+ } finally {
+ // 清理引用(Completer 已 complete,等待者不受影响)
+ _completer = null;
+ }
+ }
+
+ /// 检查 token 是否即将过期,是则主动刷新
+ ///
+ /// 解析 [currentToken] 的过期时间,距过期不足 [proactiveRefreshThreshold]
+ /// 时调用 [refreshIfNeeded] 刷新。复用串行锁和超时保护。
+ ///
+ /// 返回新 token(已刷新)或 null(不需要刷新 / 刷新失败 / 无法解析过期时间)。
+ Future proactivelyRefreshIfNeeded(String? currentToken) async {
+ if (currentToken == null || onGetTokenExpiry == null) return null;
+
+ final expiry = onGetTokenExpiry!(currentToken);
+ if (expiry == null) return null;
+
+ final remaining = expiry.difference(DateTime.now());
+ if (remaining > proactiveRefreshThreshold) {
+ _log(
+ 'Token valid (expires in ${remaining.inMinutes}min), skip proactive refresh',
+ );
+ return null;
+ }
+
+ _log(
+ 'Token expiring soon (${remaining.inMinutes}min left), proactively refreshing',
+ );
+ return refreshIfNeeded();
+ }
+
+ /// 重置状态(登出时调用)
+ void reset() {
+ _lastRefreshTime = null;
+ _lastToken = null;
+ // 不清理 _completer,让正在等待的请求正常结束
+ }
+
+ bool _isWithinReuseWindow() {
+ final lastTime = _lastRefreshTime;
+ if (lastTime == null) return false;
+ return DateTime.now().difference(lastTime) < reuseWindow;
+ }
+
+ void _log(String message) {
+ onLog?.call(message, tag: 'TokenRefresh');
+ }
+}
diff --git a/packages/networks_sdk/lib/src/data/datasources/networks_sdk_method_channel_datasource.dart b/packages/networks_sdk/lib/src/data/datasources/networks_sdk_method_channel_datasource.dart
index 9e8a418..80490b8 100644
--- a/packages/networks_sdk/lib/src/data/datasources/networks_sdk_method_channel_datasource.dart
+++ b/packages/networks_sdk/lib/src/data/datasources/networks_sdk_method_channel_datasource.dart
@@ -1,14 +1,25 @@
+import 'dart:io';
+
import 'package:dio/dio.dart';
import 'package:networks_sdk/src/data/datasources/http/api_client.dart';
import 'package:networks_sdk/src/data/dto/api_requestable.dart';
import 'package:networks_sdk/src/domain/entities/api_error.dart';
import 'package:networks_sdk/src/domain/entities/api_request_type.dart';
import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
+import 'package:networks_sdk/src/presentation/wiring/network_callbacks.dart';
import '../../../networks_sdk_platform_interface.dart';
import '../../domain/entities/http_method.dart';
-class NetworksSdkMethodChannelDataSource
-{
+/// 网络层数据源
+///
+/// 封装 [ApiClient],提供两种请求入口:
+/// - [executeRequest] — 统一请求入口(标准 / Upload / 流式)
+/// - [executeDownload] — 带进度的文件下载(支持断点续传)
+///
+/// 流式(SSE)请求也走 [executeRequest],由业务 Request 类 override
+/// `decodeResponse` 处理 SSE 解析。SDK 内部根据
+/// `requestType == ApiRequestType.stream` 自动切换 `ResponseType.plain`。
+class NetworksSdkMethodChannelDataSource {
final NetworksSdkPlatform platform;
late ApiClient apiClient;
@@ -16,44 +27,51 @@ class NetworksSdkMethodChannelDataSource
NetworksSdkMethodChannelDataSource(this.platform);
Future getPlatformVersion() async {
- return await getPlatformVersion();
+ return await platform.getPlatformVersion();
}
- void initialize(ApiConfig apiConfig){
+ void initialize(ApiConfig apiConfig) {
apiClient = ApiClient(config: apiConfig);
}
- /// 执行 API 请求 — 唯一入口
+ // ══════════════════════════════════════════════════════════════════════════
+ // 统一请求入口
+ // ══════════════════════════════════════════════════════════════════════════
+
+ /// 执行 API 请求 — 统一入口
///
- /// 流程:网络前置检查 → 构建 URL → 设置元数据 → 执行请求 → 解码响应 → 错误映射
- /// 拦截器负责:header 注入、Token 刷新重试、日志
+ /// 支持三种请求类型,由 `request.requestType` 控制行为:
+ /// - `request` / `login` — 标准 JSON 请求
+ /// - `upload` — 文件上传(FormData / 二进制)
+ /// - `stream` — SSE / chunked,内部用 `ResponseType.plain` 获取原始文本,
+ /// 由业务 Request 类 override `decodeResponse` 处理 SSE 解析
+ ///
+ /// 流程:网络前置检查 → 构建 URL → 设置元数据 → 执行请求
+ /// → 响应变换(可选,stream 类型跳过)→ 解码响应 → 错误映射
+ ///
+ /// 拦截器负责:header 注入、加密/解密、Token 刷新重试、业务错误拦截、日志
///
/// Upload 类型支持两种模式:
/// - 自有后端上传:path 为相对路径,自动拼接 baseURL
/// - S3 presigned URL:path 以 http 开头,直接使用全路径
- Future executeRequest(ApiRequestable request) async {
- // 前置检查:网络不可用时直接抛错,避免无效请求
- if (apiClient.config.onCheckNetworkAvailable != null) {
- final available = await apiClient.config.onCheckNetworkAvailable!();
- if (!available) {
- apiClient.config.onLog?.call(
- 'Network unavailable, abort request: ${request.path}',
- tag: 'ApiClient',
- );
- throw const ApiError.noNetworkConnection();
- }
- }
+ Future executeRequest(
+ ApiRequestable request, {
+ CancelToken? cancelToken,
+ }) async {
+ await _checkNetwork(request.path);
try {
- // Upload 且 path 以 http 开头 → 直接用全路径(S3 presigned URL)
- // 否则 → 拼接 baseURL
final isUpload = request.requestType == ApiRequestType.upload;
+ final isStream = request.requestType == ApiRequestType.stream;
final path = request.path;
- final url = (isUpload && path.startsWith('http')) ? path : '${apiClient.config.baseURL}$path';
+ final url = (isUpload && path.startsWith('http'))
+ ? path
+ : '${apiClient.config.baseURL}$path';
- // 将请求元数据写入 extra,供拦截器读取
final options = Options(
method: request.method.value,
+ // 流式请求用 plain,Dio 返回原始文本,由 decodeResponse 解析 SSE
+ responseType: isStream ? ResponseType.plain : null,
extra: {
'requestType': request.requestType,
'includeToken': request.includeToken,
@@ -62,19 +80,22 @@ class NetworksSdkMethodChannelDataSource
);
// 访问 parameters 触发代码生成器的 fromJson 注册
- // (@ApiRequest 生成的 mixin 在 parameters getter 中注册响应类型)
final params = request.parameters;
- // GET → queryParameters;POST/PUT/DELETE/PATCH → JSON body;Upload → uploadData
final isGet = request.method == HttpMethod.get;
final response = await apiClient.dio.request(
url,
data: isUpload ? request.uploadData : (isGet ? null : params),
queryParameters: isGet ? params : null,
options: options,
+ cancelToken: cancelToken,
);
- // 解码响应(Upload 类型通常需要 override decodeResponse)
+ // 响应变换:stream 类型由 decodeResponse 自行处理,不做变换
+ if (!isStream) {
+ _applyResponseTransform(response);
+ }
+
return request.decodeResponse(response);
} on DioException catch (e) {
throw apiClient.mapDioError(e);
@@ -85,4 +106,162 @@ class NetworksSdkMethodChannelDataSource
}
}
+ // ══════════════════════════════════════════════════════════════════════════
+ // 文件下载
+ // ══════════════════════════════════════════════════════════════════════════
+
+ /// 下载文件到本地路径
+ ///
+ /// 支持进度回调和断点续传(通过 HTTP Range header 实现)。
+ ///
+ /// 非续传模式直接用 Dio.download(高效,内部流式写入)。
+ /// 续传模式用 stream + FileMode.append,因为 Dio.download 始终从
+ /// 文件头部写入,无法正确追加到已下载部分之后。
+ ///
+ /// [url] — 下载 URL(完整路径或相对路径,相对路径自动拼接 baseURL)
+ /// [savePath] — 本地保存路径
+ /// [onProgress] — 下载进度回调
+ /// [cancelToken] — 取消令牌
+ /// [resume] — 是否断点续传(文件已存在时从断点继续下载)
+ /// [headers] — 额外请求头
+ Future executeDownload({
+ required String url,
+ required String savePath,
+ OnDownloadProgress? onProgress,
+ CancelToken? cancelToken,
+ bool resume = false,
+ Map? headers,
+ }) async {
+ await _checkNetwork(url);
+
+ try {
+ final fullUrl = url.startsWith('http')
+ ? url
+ : '${apiClient.config.baseURL}$url';
+
+ final extraHeaders = {};
+ if (headers != null) extraHeaders.addAll(headers);
+
+ // 断点续传:读取已下载部分的大小,设置 Range header
+ int startBytes = 0;
+ if (resume) {
+ final file = File(savePath);
+ if (file.existsSync()) {
+ startBytes = file.lengthSync();
+ extraHeaders['Range'] = 'bytes=$startBytes-';
+ }
+ }
+
+ if (resume && startBytes > 0) {
+ // 续传模式:stream + append,确保新数据追加到文件末尾
+ await _downloadWithResume(
+ url: fullUrl,
+ savePath: savePath,
+ startBytes: startBytes,
+ headers: extraHeaders,
+ onProgress: onProgress,
+ cancelToken: cancelToken,
+ );
+ } else {
+ // 普通下载:Dio.download(高效,内部流式写入)
+ await apiClient.dio.download(
+ fullUrl,
+ savePath,
+ cancelToken: cancelToken,
+ deleteOnError: true,
+ options: Options(
+ headers: extraHeaders.isEmpty ? null : extraHeaders,
+ extra: {
+ 'requestType': ApiRequestType.download,
+ 'includeToken': true,
+ },
+ ),
+ onReceiveProgress: onProgress != null
+ ? (received, total) => onProgress(received, total)
+ : null,
+ );
+ }
+ } on DioException catch (e) {
+ throw apiClient.mapDioError(e);
+ } on ApiError {
+ rethrow;
+ } catch (e) {
+ throw ApiError.unknown(e.toString());
+ }
+ }
+
+ /// 断点续传下载:stream 响应 + FileMode.append
+ ///
+ /// Dio.download 内部用 FileMode.write(从头覆盖),无法正确续传。
+ /// 这里手动读流并追加写入文件。
+ Future _downloadWithResume({
+ required String url,
+ required String savePath,
+ required int startBytes,
+ required Map headers,
+ OnDownloadProgress? onProgress,
+ CancelToken? cancelToken,
+ }) async {
+ final response = await apiClient.dio.get(
+ url,
+ cancelToken: cancelToken,
+ options: Options(
+ responseType: ResponseType.stream,
+ headers: headers.isEmpty ? null : headers,
+ extra: {'requestType': ApiRequestType.download, 'includeToken': true},
+ ),
+ );
+
+ final stream = response.data?.stream;
+ if (stream == null) return;
+
+ // Content-Length 是本次传输量(不含已下载部分)
+ final contentLength =
+ int.tryParse(response.headers.value('content-length') ?? '') ?? -1;
+ final totalBytes = contentLength > 0 ? contentLength + startBytes : -1;
+
+ final file = File(savePath);
+ final raf = file.openSync(mode: FileMode.writeOnlyAppend);
+ int received = startBytes;
+
+ try {
+ await for (final chunk in stream) {
+ raf.writeFromSync(chunk);
+ received += chunk.length;
+ onProgress?.call(received, totalBytes);
+ }
+ } finally {
+ raf.closeSync();
+ }
+ }
+
+ // ══════════════════════════════════════════════════════════════════════════
+ // 内部辅助
+ // ══════════════════════════════════════════════════════════════════════════
+
+ /// 网络前置检查,不可用时直接抛 [ApiError.noNetworkConnection]
+ Future _checkNetwork(String path) async {
+ if (apiClient.config.onCheckNetworkAvailable != null) {
+ final available = await apiClient.config.onCheckNetworkAvailable!();
+ if (!available) {
+ apiClient.config.onLog?.call(
+ 'Network unavailable, abort request: $path',
+ tag: 'ApiClient',
+ );
+ throw const ApiError.noNetworkConnection();
+ }
+ }
+ }
+
+ /// 应用响应变换(如果 App 层注入了 onTransformResponse)
+ void _applyResponseTransform(Response response) {
+ final transform = apiClient.config.onTransformResponse;
+ if (transform == null) return;
+ if (response.data is! Map) return;
+
+ final transformed = transform(response.data as Map);
+ if (transformed != null) {
+ response.data = transformed;
+ }
+ }
}
diff --git a/packages/networks_sdk/lib/src/data/datasources/socket/socket_client.dart b/packages/networks_sdk/lib/src/data/datasources/socket/socket_client.dart
index 84f47d0..a84b856 100644
--- a/packages/networks_sdk/lib/src/data/datasources/socket/socket_client.dart
+++ b/packages/networks_sdk/lib/src/data/datasources/socket/socket_client.dart
@@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:convert';
+import 'dart:io' as io;
import 'dart:math';
+import 'dart:typed_data';
import 'package:networks_sdk/networks_sdk.dart';
import 'package:web_socket_channel/io.dart';
@@ -9,10 +11,12 @@ import 'package:web_socket_channel/web_socket_channel.dart';
/// WebSocket 长连接客户端
///
/// 提供:
-/// - 连接 / 断连 / 自动重连(指数退避)
+/// - 连接 / 断连 / 自动重连(指数退避,支持无限重连)
/// - 双层心跳(底层 ping + 应用层 heartbeat)
-/// - Stream 输出(消息、连接状态、错误)
+/// - Stream 输出(JSON 消息、原始字符串、二进制、连接状态、错误)
/// - 生命周期感知(前后台切换)
+/// - Token 热更新(不断连)
+/// - 消息加密/解密钩子(预留给 cipher_guard_sdk)
///
/// ## 使用方式
///
@@ -28,6 +32,9 @@ import 'package:web_socket_channel/web_socket_channel.dart';
/// // 发消息
/// await client.send({'type': 'chat', 'data': {...}});
///
+/// // Token 热更新(不断连,下次重连自动使用新 token)
+/// client.updateToken('new_token');
+///
/// // 断连
/// await client.disconnect();
/// ```
@@ -56,8 +63,9 @@ class SocketClient {
// ── Stream Controllers ──
final _messageController = StreamController