本次更新涵盖多个功能模块的优化与新增: 1. 新增Wi-Fi直连配对方式与协作画布模块 2. 完成设备管理重命名API与前端适配 3. 优化日签卡片空数据保护与UI细节 4. 新增剪贴板工具与每日运势会话 5. 修复应用锁恢复、语音消息录制等已知问题 6. 完善文件传输统计与配对逻辑 7. 更新安卓权限配置与iOS隐私描述 8. 新增自动化测试脚本与文档 9. 清理旧版审计报告与测试文件
259 lines
7.6 KiB
Dart
259 lines
7.6 KiB
Dart
// ============================================================
|
|
// 闲言APP — 离线队列服务
|
|
// 创建时间: 2026-05-14
|
|
// 更新时间: 2026-05-14
|
|
// 作用: 离线暂存+自动重发+状态监控 — 消息/文件离线队列管理
|
|
// 上次更新: v6.4.0 初始创建
|
|
// ============================================================
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
import 'package:xianyan/core/utils/logger.dart';
|
|
import 'package:xianyan/features/file_transfer/database/transfer_database.dart';
|
|
import 'package:xianyan/features/file_transfer/models/offline_queue_item.dart';
|
|
|
|
typedef OnResendCallback = Future<bool> Function(OfflineQueueItem item);
|
|
|
|
class OfflineQueueService {
|
|
OfflineQueueService({required TransferDatabase db}) : _db = db;
|
|
|
|
final TransferDatabase _db;
|
|
final _uuid = const Uuid();
|
|
final _statusController = StreamController<OfflineQueueStatus>.broadcast();
|
|
|
|
OnResendCallback? _onResend;
|
|
|
|
Stream<OfflineQueueStatus> get onStatusChanged => _statusController.stream;
|
|
|
|
void setResendCallback(OnResendCallback callback) {
|
|
_onResend = callback;
|
|
}
|
|
|
|
Future<OfflineQueueItem> enqueueMessage({
|
|
required String targetDeviceId,
|
|
required String content,
|
|
String? messageId,
|
|
String? deviceAlias,
|
|
}) async {
|
|
final item = OfflineQueueItem(
|
|
id: _uuid.v4(),
|
|
targetDeviceId: targetDeviceId,
|
|
type: OfflineQueueItemType.text,
|
|
content: content,
|
|
status: OfflineQueueItemStatus.pending,
|
|
createdAt: DateTime.now(),
|
|
messageId: messageId,
|
|
deviceAlias: deviceAlias,
|
|
);
|
|
await _db.insertOfflineQueueItem(item);
|
|
Log.i('OfflineQueue: Enqueued text message for $targetDeviceId');
|
|
_notifyStatus();
|
|
return item;
|
|
}
|
|
|
|
Future<OfflineQueueItem> enqueueFile({
|
|
required String targetDeviceId,
|
|
required String filePath,
|
|
required String fileName,
|
|
int? fileSize,
|
|
String? deviceAlias,
|
|
}) async {
|
|
final item = OfflineQueueItem(
|
|
id: _uuid.v4(),
|
|
targetDeviceId: targetDeviceId,
|
|
type: OfflineQueueItemType.file,
|
|
filePath: filePath,
|
|
fileName: fileName,
|
|
fileSize: fileSize,
|
|
status: OfflineQueueItemStatus.pending,
|
|
createdAt: DateTime.now(),
|
|
deviceAlias: deviceAlias,
|
|
);
|
|
await _db.insertOfflineQueueItem(item);
|
|
Log.i('OfflineQueue: Enqueued file "$fileName" for $targetDeviceId');
|
|
_notifyStatus();
|
|
return item;
|
|
}
|
|
|
|
Future<OfflineQueueItem> enqueueVoice({
|
|
required String targetDeviceId,
|
|
required String filePath,
|
|
String? deviceAlias,
|
|
}) async {
|
|
final item = OfflineQueueItem(
|
|
id: _uuid.v4(),
|
|
targetDeviceId: targetDeviceId,
|
|
type: OfflineQueueItemType.voice,
|
|
filePath: filePath,
|
|
fileName: 'voice_message.m4a',
|
|
status: OfflineQueueItemStatus.pending,
|
|
createdAt: DateTime.now(),
|
|
deviceAlias: deviceAlias,
|
|
);
|
|
await _db.insertOfflineQueueItem(item);
|
|
Log.i('OfflineQueue: Enqueued voice message for $targetDeviceId');
|
|
_notifyStatus();
|
|
return item;
|
|
}
|
|
|
|
Future<List<OfflineQueueItem>> getPendingItems(String deviceId) async {
|
|
return _db.getPendingOfflineItems(deviceId);
|
|
}
|
|
|
|
Future<List<OfflineQueueItem>> getAllPendingItems() async {
|
|
return _db.getAllPendingOfflineItems();
|
|
}
|
|
|
|
Future<int> getPendingCount(String deviceId) async {
|
|
return _db.getPendingOfflineCount(deviceId);
|
|
}
|
|
|
|
Future<int> getTotalPendingCount() async {
|
|
return _db.getTotalPendingCount();
|
|
}
|
|
|
|
Future<void> markAsSent(String id) async {
|
|
await _db.updateOfflineQueueItemStatus(id, OfflineQueueItemStatus.sent);
|
|
Log.i('OfflineQueue: Item $id marked as sent');
|
|
_notifyStatus();
|
|
}
|
|
|
|
Future<void> markAsFailed(String id, String error) async {
|
|
final items = await _db.getAllOfflineItems();
|
|
final item = items.where((i) => i.id == id).firstOrNull;
|
|
final newRetryCount = (item?.retryCount ?? 0) + 1;
|
|
|
|
if (newRetryCount >= OfflineQueueItem.maxRetryCount) {
|
|
await _db.updateOfflineQueueItemStatus(
|
|
id,
|
|
OfflineQueueItemStatus.failed,
|
|
errorMessage: '$error (已达最大重试次数)',
|
|
retryCount: newRetryCount,
|
|
);
|
|
Log.w('OfflineQueue: Item $id failed permanently after $newRetryCount retries');
|
|
} else {
|
|
await _db.updateOfflineQueueItemStatus(
|
|
id,
|
|
OfflineQueueItemStatus.failed,
|
|
errorMessage: error,
|
|
retryCount: newRetryCount,
|
|
);
|
|
Log.w('OfflineQueue: Item $id failed (retry $newRetryCount/${OfflineQueueItem.maxRetryCount}): $error');
|
|
}
|
|
_notifyStatus();
|
|
}
|
|
|
|
Future<void> markAsSending(String id) async {
|
|
await _db.updateOfflineQueueItemStatus(id, OfflineQueueItemStatus.sending);
|
|
_notifyStatus();
|
|
}
|
|
|
|
Future<void> deleteItem(String id) async {
|
|
await _db.deleteOfflineQueueItem(id);
|
|
_notifyStatus();
|
|
}
|
|
|
|
Future<void> clearPendingForDevice(String deviceId) async {
|
|
await _db.deleteOfflineItemsByDevice(deviceId);
|
|
Log.i('OfflineQueue: Cleared pending items for $deviceId');
|
|
_notifyStatus();
|
|
}
|
|
|
|
Future<void> cleanOldItems({int maxAgeDays = 7}) async {
|
|
await _db.cleanOldOfflineItems(maxAgeDays: maxAgeDays);
|
|
Log.i('OfflineQueue: Cleaned items older than $maxAgeDays days');
|
|
_notifyStatus();
|
|
}
|
|
|
|
Future<void> onDeviceOnline(String deviceId) async {
|
|
Log.i('OfflineQueue: Device $deviceId came online, processing pending items');
|
|
final pendingItems = await _db.getPendingOfflineItems(deviceId);
|
|
if (pendingItems.isEmpty) {
|
|
Log.i('OfflineQueue: No pending items for $deviceId');
|
|
return;
|
|
}
|
|
|
|
Log.i('OfflineQueue: Processing ${pendingItems.length} pending items for $deviceId');
|
|
|
|
for (final item in pendingItems) {
|
|
if (!item.status.isPending && !item.status.isFailed) continue;
|
|
if (item.status.isFailed && !item.canRetry) continue;
|
|
|
|
await _resendItem(item);
|
|
}
|
|
}
|
|
|
|
Future<bool> _resendItem(OfflineQueueItem item) async {
|
|
if (_onResend == null) {
|
|
Log.w('OfflineQueue: No resend callback set, skipping ${item.id}');
|
|
return false;
|
|
}
|
|
|
|
await markAsSending(item.id);
|
|
|
|
try {
|
|
final success = await _onResend!(item);
|
|
if (success) {
|
|
await markAsSent(item.id);
|
|
return true;
|
|
} else {
|
|
await markAsFailed(item.id, '重发返回失败');
|
|
return false;
|
|
}
|
|
} catch (e) {
|
|
await markAsFailed(item.id, e.toString());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<OfflineQueueStatus> getCurrentStatus() async {
|
|
final allItems = await _db.getAllOfflineItems();
|
|
final byDevice = <String, List<OfflineQueueItem>>{};
|
|
for (final item in allItems) {
|
|
byDevice.putIfAbsent(item.targetDeviceId, () => []).add(item);
|
|
}
|
|
final totalPending = allItems.where((i) => !i.status.isTerminal).length;
|
|
return OfflineQueueStatus(
|
|
itemsByDevice: byDevice,
|
|
totalPendingCount: totalPending,
|
|
);
|
|
}
|
|
|
|
void _notifyStatus() {
|
|
getCurrentStatus().then((status) {
|
|
if (!_statusController.isClosed) {
|
|
_statusController.add(status);
|
|
}
|
|
}).catchError((Object e) {
|
|
Log.w('OfflineQueue: Failed to notify status: $e');
|
|
});
|
|
}
|
|
|
|
Future<void> dispose() async {
|
|
await _statusController.close();
|
|
}
|
|
}
|
|
|
|
class OfflineQueueStatus {
|
|
const OfflineQueueStatus({
|
|
required this.itemsByDevice,
|
|
required this.totalPendingCount,
|
|
});
|
|
|
|
final Map<String, List<OfflineQueueItem>> itemsByDevice;
|
|
final int totalPendingCount;
|
|
|
|
List<OfflineQueueItem> pendingItemsForDevice(String deviceId) {
|
|
return itemsByDevice[deviceId]
|
|
?.where((i) => !i.status.isTerminal)
|
|
.toList() ??
|
|
[];
|
|
}
|
|
|
|
int pendingCountForDevice(String deviceId) {
|
|
return pendingItemsForDevice(deviceId).length;
|
|
}
|
|
}
|