Skip to content

Commit 819e954

Browse files
committed
feat(v1.3): 强化搜索定位与安全传输收尾
变更内容: - 为聊天搜索增加历史结果定位能力,支持从未加载页直接载入命中消息所在页,并在刷新时合并已加载历史避免视图被最新 50 条重置。 - 调整链接打开逻辑,Windows 改为直接启动 explorer.exe,避免通过 cmd.exe 解释消息中的特殊字符。 - 传输取消流程新增主动通知对端收尾,支持活动传输和整组传输先远端取消再本地中止,并补充传输中心相关状态展示。 - 强化身份与发布安全:安全存储缺失私钥时失败关闭,Android release 构建改为强制固定签名,不再静默回退 debug 签名。 - 补充聊天页分页搜索、密钥迁移失败关闭、活动流取消清理接收端、陈旧 queued/sending 恢复等测试。 影响范围: - 影响聊天页搜索跳转、刷新保留历史、消息链接打开、传输取消收尾以及 Android release 打包策略。 - 本次未提交 pubspec.lock 的镜像源地址变更,也未包含未跟踪的 dist 目录。 验证: - 已运行 . .\\scripts\\flutter_env.ps1; flutter test test/chat_page_test.dart test/secure_key_migration_test.dart test/transfer_queue_test.dart - 结果: 17 tests passed. - 未运行 Android release 构建,原因: 当前提交将缺少 keystore.properties 视为失败,未在本次提交流程中提供发布签名凭据。
1 parent 8d49ab2 commit 819e954

10 files changed

Lines changed: 676 additions & 330 deletions

android/app/build.gradle.kts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ plugins {
77
id("dev.flutter.flutter-gradle-plugin")
88
}
99

10-
// 读取本地签名凭据(keystore.properties,不入库)。缺失时回退 debug 签名,
11-
// 保证无证书环境仍可构建;正式发布需提供该文件与对应 .jks(计划 P0:固定签名)
10+
// 读取本地签名凭据(keystore.properties,不入库)。Release 始终使用固定签名;
11+
// 缺少凭据时让构建失败,避免静默产出无法覆盖升级的 debug 签名安装包
1212
val keystoreProperties = Properties()
1313
val keystoreFile = rootProject.file("keystore.properties")
1414
if (keystoreFile.exists()) {
@@ -47,12 +47,7 @@ android {
4747

4848
buildTypes {
4949
release {
50-
// 优先使用固定 release 签名;凭据缺失时回退 debug,保证可构建。
51-
signingConfig = if (keystoreProperties.containsKey("storeFile")) {
52-
signingConfigs.getByName("release")
53-
} else {
54-
signingConfigs.getByName("debug")
55-
}
50+
signingConfig = signingConfigs.getByName("release")
5651
}
5752
}
5853
}

lib/app/app_controller.dart

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ class AppController extends ChangeNotifier {
6060
late final SecurityService securityService;
6161
late final TransportService transportService;
6262
late final DiscoveryService discoveryService;
63+
6364
/// 设置子控制器:语言/外观/自动复制/托盘/开机自启。对外字段与方法通过下方 getter
6465
/// 与同名方法委托,UI 无需感知拆分(计划 P1 控制器拆分)。
6566
late final SettingsController settings;
@@ -69,10 +70,13 @@ class AppController extends ChangeNotifier {
6970
List<Conversation> conversations = [];
7071
List<ChatMessage> messages = [];
7172
Map<String, Transfer> transfersById = {};
73+
7274
/// 各会话未读数(conversationId -> count)。
7375
Map<String, int> unreadCounts = {};
76+
7477
/// 各会话最后一条消息预览(conversationId -> message)。
7578
Map<String, ChatMessage> lastMessages = {};
79+
7680
/// 会话列表名称过滤关键字。
7781
String conversationFilter = '';
7882

@@ -81,15 +85,18 @@ class AppController extends ChangeNotifier {
8185
conversationFilter = value;
8286
notifyListeners();
8387
}
88+
8489
/// 当前会话分页游标(用于判断是否还有更早的页 + 向前加载)。
8590
ChatPageCursor? messageCursor;
91+
8692
/// 当前会话是否还有更早的消息可加载。
8793
bool hasMoreMessages = false;
8894
Device? selectedDevice;
8995
Conversation? selectedConversation;
9096
PendingPairRequest? pendingPairRequest;
9197
bool initialized = false;
9298
bool busy = false;
99+
93100
/// 细粒度操作状态:记录正在进行的特定操作(配对、重试等),替代仅靠全局 busy
94101
/// 禁用输入的做法。文件传输已入队异步执行,不再阻塞输入框(计划 P1)。
95102
final Set<String> activeOperations = {};
@@ -205,12 +212,22 @@ class AppController extends ChangeNotifier {
205212
selectedDevice;
206213
selectedConversation = await db.ensureConversation(selectedDevice!);
207214
// 分页:刷新时只重载最新一页,避免大历史全量加载。
208-
final page = await db.listMessagesPage(
215+
final latestPage = await db.listMessagesPage(
209216
conversationId: selectedConversation!.id,
210217
limit: 50,
211218
);
212-
messages = page;
213-
_updateMessageCursor(page);
219+
// 保留用户已经加载的旧页或搜索定位页,只合并最新页的状态与新增消息。
220+
// 否则传输进度/在线状态触发的 refresh 会每 200ms 把视图重置回最新 50 条。
221+
final merged =
222+
<String, ChatMessage>{
223+
for (final message in messages) message.id: message,
224+
for (final message in latestPage) message.id: message,
225+
}.values.toList()..sort((a, b) {
226+
final byTime = a.createdAt.compareTo(b.createdAt);
227+
return byTime != 0 ? byTime : a.id.compareTo(b.id);
228+
});
229+
messages = merged;
230+
_updateMessageCursor(messages);
214231
final transferIds = messages
215232
.map((message) => message.transferId)
216233
.whereType<String>();
@@ -287,6 +304,26 @@ class AppController extends ChangeNotifier {
287304
return db.searchMessages(conversation.id, query.trim());
288305
}
289306

307+
/// 将搜索命中的历史消息所在页载入当前会话,避免只搜索到结果却无法定位。
308+
Future<bool> loadSearchResult(ChatMessage target) async {
309+
final conversation = selectedConversation;
310+
if (conversation == null || target.conversationId != conversation.id) {
311+
return false;
312+
}
313+
if (messages.any((message) => message.id == target.id)) return true;
314+
final page = await db.listMessagesPageEndingAt(
315+
conversationId: conversation.id,
316+
createdAt: target.createdAt,
317+
id: target.id,
318+
limit: 50,
319+
);
320+
messages = page;
321+
_updateMessageCursor(page);
322+
await _ensureTransfersForCurrentMessages();
323+
notifyListeners();
324+
return page.any((message) => message.id == target.id);
325+
}
326+
290327
void _updateMessageCursor(List<ChatMessage> page) {
291328
if (page.isEmpty) {
292329
messageCursor = null;
@@ -424,9 +461,7 @@ class AppController extends ChangeNotifier {
424461
Future<void> setAutostartEnabled(bool value) async {
425462
await settings.setAutostartEnabled(value);
426463
status = value
427-
? (settings.languageCode == 'en'
428-
? 'Start on boot enabled'
429-
: '已开启开机自启')
464+
? (settings.languageCode == 'en' ? 'Start on boot enabled' : '已开启开机自启')
430465
: (settings.languageCode == 'en'
431466
? 'Start on boot disabled'
432467
: '已关闭开机自启');
@@ -759,10 +794,12 @@ class AppController extends ChangeNotifier {
759794

760795
/// 打开消息中的链接:Windows 用默认浏览器,其他平台用 OpenFilex。
761796
Future<void> openUrl(String url) async {
762-
if (url.isEmpty) return;
797+
final uri = Uri.tryParse(url);
798+
if (uri == null || (uri.scheme != 'http' && uri.scheme != 'https')) return;
763799
if (Platform.isWindows) {
764800
try {
765-
await Process.run('cmd', ['/c', 'start', '', url]);
801+
// 不经过 cmd.exe,避免消息中的 &、| 等字符被解释为 shell 命令。
802+
await Process.start('explorer.exe', [url]);
766803
return;
767804
} catch (_) {}
768805
}
@@ -955,11 +992,17 @@ class AppController extends ChangeNotifier {
955992
/// 取消一个出站传输。优先本地取消(排队或活动任务);若该任务已发送到对端且
956993
/// 对端支持取消能力,则同时请求对端中断接收。返回是否成功取消。
957994
Future<bool> cancelTransfer(Transfer transfer) async {
995+
final wasActive = transportService.isOutboundActive(transfer.id);
996+
if (wasActive) {
997+
final peer = await db.getDevice(transfer.peerDeviceId);
998+
if (peer != null && transportService.deviceSupportsCancel(peer)) {
999+
// 先让接收端关闭并清理临时文件,再中止本地发送流。
1000+
await transportService.requestRemoteCancel(peer, transfer.id);
1001+
}
1002+
}
9581003
final localCanceled = await transportService.cancelOutbound(transfer.id);
9591004
if (localCanceled) {
960-
status = languageCode == 'en'
961-
? 'Transfer canceled'
962-
: '已取消传输';
1005+
status = languageCode == 'en' ? 'Transfer canceled' : '已取消传输';
9631006
notifyListeners();
9641007
return true;
9651008
}
@@ -976,15 +1019,26 @@ class AppController extends ChangeNotifier {
9761019
final ok = await transportService.requestRemoteCancel(peer, transfer.id);
9771020
status = ok
9781021
? (languageCode == 'en' ? 'Transfer canceled' : '已取消传输')
979-
: (languageCode == 'en'
980-
? 'Transfer could not be canceled'
981-
: '无法取消该传输');
1022+
: (languageCode == 'en' ? 'Transfer could not be canceled' : '无法取消该传输');
9821023
notifyListeners();
9831024
return ok;
9841025
}
9851026

9861027
/// 取消整组出站传输(文件夹或批量附件)。
9871028
Future<int> cancelTransferGroup(String groupId) async {
1029+
final activeTransferId = transportService.activeOutboundTransferIdForGroup(
1030+
groupId,
1031+
);
1032+
if (activeTransferId != null) {
1033+
final transfers = await db.listTransfersByIds([activeTransferId]);
1034+
final active = transfers.firstOrNull;
1035+
if (active != null) {
1036+
final peer = await db.getDevice(active.peerDeviceId);
1037+
if (peer != null && transportService.deviceSupportsCancel(peer)) {
1038+
await transportService.requestRemoteCancel(peer, active.id);
1039+
}
1040+
}
1041+
}
9881042
final count = await transportService.cancelOutboundGroup(groupId);
9891043
status = languageCode == 'en'
9901044
? 'Canceled $count transfer(s)'

lib/data/app_database.dart

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,8 @@ class AppDatabase extends _$AppDatabase {
264264
// 发现信息只刷新地址、名称、头像、在线状态与能力;签名/交换公钥/指纹绝不
265265
// 被覆盖。检测到任一身份字段变化即标记 identity_changed,发送侧据此拦截,
266266
// 要求用户删除后重新配对。
267-
final identityChanged = existing.identityChanged == true ||
267+
final identityChanged =
268+
existing.identityChanged == true ||
268269
signingPublicKey != existing.signingPublicKey ||
269270
exchangePublicKey != existing.exchangePublicKey ||
270271
fingerprint != existing.fingerprint;
@@ -543,6 +544,29 @@ class AppDatabase extends _$AppDatabase {
543544
});
544545
}
545546

547+
/// 读取以指定消息为末尾的一页,用于从全历史搜索结果直接定位到尚未加载的消息。
548+
Future<List<ChatMessage>> listMessagesPageEndingAt({
549+
required String conversationId,
550+
required DateTime createdAt,
551+
required String id,
552+
int limit = 50,
553+
}) {
554+
final query = select(chatMessages)
555+
..where((tbl) => tbl.conversationId.equals(conversationId))
556+
..where(
557+
(tbl) =>
558+
tbl.createdAt.isSmallerThanValue(createdAt) |
559+
(tbl.createdAt.equals(createdAt) &
560+
(tbl.id.isSmallerThanValue(id) | tbl.id.equals(id))),
561+
)
562+
..orderBy([
563+
(tbl) => OrderingTerm.desc(tbl.createdAt),
564+
(tbl) => OrderingTerm.desc(tbl.id),
565+
])
566+
..limit(limit);
567+
return query.get().then((rows) => rows.reversed.toList());
568+
}
569+
546570
/// 计算会话在 lastReadAt 之后的未读消息数。lastReadAt 为空时全部计入。
547571
Future<int> unreadCount(String conversationId, {DateTime? lastReadAt}) async {
548572
final query = select(chatMessages)
@@ -561,22 +585,21 @@ class AppDatabase extends _$AppDatabase {
561585
String query,
562586
) async {
563587
final like = '%$query%';
564-
final rows = await (select(chatMessages)
565-
..where((tbl) => tbl.conversationId.equals(conversationId))
566-
..where(
567-
(tbl) =>
568-
(tbl.body.like(like)) | (tbl.fileName.like(like)),
569-
)
570-
..orderBy([(tbl) => OrderingTerm.asc(tbl.createdAt)])
571-
..limit(100))
572-
.get();
588+
final rows =
589+
await (select(chatMessages)
590+
..where((tbl) => tbl.conversationId.equals(conversationId))
591+
..where(
592+
(tbl) => (tbl.body.like(like)) | (tbl.fileName.like(like)),
593+
)
594+
..orderBy([(tbl) => OrderingTerm.asc(tbl.createdAt)])
595+
..limit(100))
596+
.get();
573597
return rows;
574598
}
575599

576600
/// 更新会话最后阅读时间,用于未读统计清零。
577601
Future<void> markConversationRead(String conversationId) async {
578-
await (update(conversations)
579-
..where((tbl) => tbl.id.equals(conversationId)))
602+
await (update(conversations)..where((tbl) => tbl.id.equals(conversationId)))
580603
.write(ConversationsCompanion(lastReadAt: Value(DateTime.now())));
581604
}
582605

0 commit comments

Comments
 (0)