From d40a24c1ba58cf24804fc485fa021ae5ea6e1e70 Mon Sep 17 00:00:00 2001 From: Mazha0309 Date: Sun, 30 Aug 2026 18:30:55 +0800 Subject: [PATCH] feat: stabilize high-latency live draft collaboration --- lib/config/version.dart | 2 +- lib/models/live_draft.dart | 308 +++ lib/providers/collaboration_provider.dart | 2081 +++++++++++++++-- lib/services/server_api.dart | 149 +- lib/widgets/callsign_history_field.dart | 352 ++- lib/widgets/log_form.dart | 282 ++- pubspec.yaml | 2 +- rust/Cargo.lock | 2 +- rust/Cargo.toml | 2 +- test/models/live_draft_test.dart | 120 + .../collaboration_provider_test.dart | 528 ++++- test/services/server_api_test.dart | 134 +- test/widgets/callsign_history_field_test.dart | 215 ++ test/widgets/log_form_collaboration_test.dart | 267 +++ 14 files changed, 4158 insertions(+), 286 deletions(-) diff --git a/lib/config/version.dart b/lib/config/version.dart index 55466a6..d54d0f1 100644 --- a/lib/config/version.dart +++ b/lib/config/version.dart @@ -1 +1 @@ -const String appVersion = '2.9.3-R'; +const String appVersion = '2.9.4-R'; diff --git a/lib/models/live_draft.dart b/lib/models/live_draft.dart index c7210ff..70645c3 100644 --- a/lib/models/live_draft.dart +++ b/lib/models/live_draft.dart @@ -163,6 +163,32 @@ final class LiveDraftLockDto { }; } +/// Result of acquiring a field lease. +/// +/// Servers predating the high-latency live-draft protocol enhancement only +/// return [lock]. Newer servers also include the canonical [draft] captured +/// when the lease was granted, allowing the client to rebase without another +/// network round trip. +final class LiveDraftLockAcquisitionDto { + const LiveDraftLockAcquisitionDto({ + required this.lock, + required this.draft, + }); + + factory LiveDraftLockAcquisitionDto.fromJson(Object? json) { + final object = _object(json, 'liveDraftLockResult'); + return LiveDraftLockAcquisitionDto( + lock: LiveDraftLockDto.fromJson(object['lock']), + draft: object['draft'] == null + ? null + : LiveDraftDto.fromJson(object['draft']), + ); + } + + final LiveDraftLockDto lock; + final LiveDraftDto? draft; +} + final class LiveDraftSnapshotDto { const LiveDraftSnapshotDto({ required this.draft, @@ -170,6 +196,7 @@ final class LiveDraftSnapshotDto { required this.currentOrdinal, required this.totalRecords, required this.previousRecord, + this.historyPreview, }); factory LiveDraftSnapshotDto.fromJson(Object? json) { @@ -186,6 +213,9 @@ final class LiveDraftSnapshotDto { previousRecord: object['previousRecord'] == null ? null : CollaborationLogDto.fromJson(object['previousRecord']), + historyPreview: object['historyPreview'] == null + ? null + : LiveDraftHistoryPreviewDto.fromJson(object['historyPreview']), ); } @@ -194,6 +224,7 @@ final class LiveDraftSnapshotDto { final int currentOrdinal; final int totalRecords; final CollaborationLogDto? previousRecord; + final LiveDraftHistoryPreviewDto? historyPreview; JsonObject toJson() => { 'draft': draft.toJson(), @@ -230,11 +261,276 @@ final class LiveDraftPatchUpdateDto { }; } +final class LiveDraftReleasedLeaseDto { + const LiveDraftReleasedLeaseDto({ + required this.field, + required this.leaseId, + }); + + factory LiveDraftReleasedLeaseDto.fromJson(Object? json) { + final object = _object(json, 'releasedLease'); + final field = _string(object, 'field'); + if (!liveDraftFieldNames.contains(field)) { + throw FormatException('releasedLease.field is unsupported: $field'); + } + return LiveDraftReleasedLeaseDto( + field: field, + leaseId: _string(object, 'leaseId'), + ); + } + + final String field; + final String leaseId; + + JsonObject toJson() => {'field': field, 'leaseId': leaseId}; +} + +const Set liveDraftHistoryReusableFieldNames = { + 'qth', + 'device', + 'power', + 'antenna', + 'height', +}; + +/// One local-history row that may be previewed to the other scribes. +/// +/// [sourceTime] is provenance only. Applying a candidate never writes it into +/// the shared draft's `time` field. +final class LiveDraftHistoryCandidateDto { + const LiveDraftHistoryCandidateDto({ + required this.candidateId, + required this.sourceTime, + required this.qth, + required this.device, + required this.power, + required this.antenna, + required this.height, + }); + + factory LiveDraftHistoryCandidateDto.fromJson(Object? json) { + final object = _object(json, 'liveDraftHistoryCandidate'); + return LiveDraftHistoryCandidateDto( + candidateId: _string(object, 'candidateId'), + sourceTime: _string(object, 'sourceTime'), + qth: _nullableText(object['qth'], 'qth'), + device: _nullableText(object['device'], 'device'), + power: _nullableText(object['power'], 'power'), + antenna: _nullableText(object['antenna'], 'antenna'), + height: _nullableText(object['height'], 'height'), + ); + } + + final String candidateId; + final String sourceTime; + final String qth; + final String device; + final String power; + final String antenna; + final String height; + + Map get reusableValues => { + if (qth.isNotEmpty) 'qth': qth, + if (device.isNotEmpty) 'device': device, + if (power.isNotEmpty) 'power': power, + if (antenna.isNotEmpty) 'antenna': antenna, + if (height.isNotEmpty) 'height': height, + }; + + JsonObject toJson() => { + 'candidateId': candidateId, + 'sourceTime': sourceTime, + 'qth': qth, + 'device': device, + 'power': power, + 'antenna': antenna, + 'height': height, + }; +} + +/// Ephemeral history dropdown owned by the device editing `callsign`. +final class LiveDraftHistoryPreviewDto { + const LiveDraftHistoryPreviewDto({ + required this.previewId, + required this.draftId, + required this.deviceId, + required this.callsign, + required this.actor, + required this.expiresAt, + required this.candidates, + }); + + factory LiveDraftHistoryPreviewDto.fromJson(Object? json) { + final object = _object(json, 'liveDraftHistoryPreview'); + final candidateValues = object['candidates']; + if (candidateValues is! List) { + throw const FormatException('historyPreview.candidates must be an array'); + } + return LiveDraftHistoryPreviewDto( + previewId: _string(object, 'previewId'), + draftId: _string(object, 'draftId'), + deviceId: _string(object, 'deviceId'), + callsign: _string(object, 'callsign').trim().toUpperCase(), + actor: LiveDraftActorDto.fromJson(object['actor']), + expiresAt: _dateTime(object, 'expiresAt'), + candidates: List.unmodifiable( + candidateValues.map(LiveDraftHistoryCandidateDto.fromJson), + ), + ); + } + + final String previewId; + final String draftId; + final String deviceId; + final String callsign; + final LiveDraftActorDto actor; + final DateTime expiresAt; + final List candidates; + + JsonObject toJson() => { + 'previewId': previewId, + 'draftId': draftId, + 'deviceId': deviceId, + 'callsign': callsign, + 'actor': actor.toJson(), + 'expiresAt': expiresAt.toUtc().toIso8601String(), + 'candidates': candidates + .map((candidate) => candidate.toJson()) + .toList(growable: false), + }; +} + +/// Metadata proving that a canonical update came from an explicit history +/// candidate selection, rather than ordinary remote typing. +final class LiveDraftHistoryReuseDto { + const LiveDraftHistoryReuseDto({ + required this.previewId, + required this.candidateId, + required this.affectedFields, + }); + + factory LiveDraftHistoryReuseDto.fromJson(Object? json) { + final object = _object(json, 'liveDraftHistoryReuse'); + final fieldValues = object['affectedFields']; + if (fieldValues is! List || fieldValues.any((value) => value is! String)) { + throw const FormatException( + 'historyReuse.affectedFields must be an array'); + } + final fields = fieldValues.cast().toSet(); + if (!liveDraftHistoryReusableFieldNames.containsAll(fields)) { + throw const FormatException('historyReuse contains an unsupported field'); + } + return LiveDraftHistoryReuseDto( + previewId: _string(object, 'previewId'), + candidateId: _string(object, 'candidateId'), + affectedFields: Set.unmodifiable(fields), + ); + } + + final String previewId; + final String candidateId; + final Set affectedFields; + + JsonObject toJson() => { + 'previewId': previewId, + 'candidateId': candidateId, + 'affectedFields': affectedFields.toList(growable: false), + }; +} + +final class LiveDraftHistoryPreviewResultDto { + const LiveDraftHistoryPreviewResultDto({ + required this.historyPreview, + required this.draft, + required this.locks, + }); + + factory LiveDraftHistoryPreviewResultDto.fromJson(Object? json) { + final object = _object(json, 'liveDraftHistoryPreviewResult'); + final lockValues = object['locks']; + if (lockValues is! List) { + throw const FormatException('history preview locks must be an array'); + } + return LiveDraftHistoryPreviewResultDto( + historyPreview: + LiveDraftHistoryPreviewDto.fromJson(object['historyPreview']), + draft: LiveDraftDto.fromJson(object['draft']), + locks: List.unmodifiable( + lockValues.map(LiveDraftLockDto.fromJson), + ), + ); + } + + final LiveDraftHistoryPreviewDto historyPreview; + final LiveDraftDto draft; + final List locks; +} + +final class LiveDraftHistoryReuseResultDto { + const LiveDraftHistoryReuseResultDto({ + required this.draft, + required this.updatedFields, + required this.releasedLeases, + required this.historyReuse, + required this.locks, + }); + + factory LiveDraftHistoryReuseResultDto.fromJson(Object? json) { + final object = _object(json, 'liveDraftHistoryReuseResult'); + final lockValues = object['locks']; + final updatedFieldValues = object['updatedFields']; + if (updatedFieldValues is! List || + updatedFieldValues.any((value) => value is! String)) { + throw const FormatException( + 'history reuse updatedFields must be an array', + ); + } + final updatedFields = updatedFieldValues.cast().toSet(); + if (!liveDraftHistoryReusableFieldNames.containsAll(updatedFields)) { + throw const FormatException( + 'history reuse updatedFields contains an unsupported field', + ); + } + if (lockValues is! List) { + throw const FormatException('history reuse locks must be an array'); + } + final historyReuse = + LiveDraftHistoryReuseDto.fromJson(object['historyReuse']); + if (updatedFields.length != historyReuse.affectedFields.length || + !updatedFields.containsAll(historyReuse.affectedFields)) { + throw const FormatException( + 'history reuse updatedFields do not match affectedFields', + ); + } + if (object['historyPreview'] != null) { + throw const FormatException( + 'history reuse must clear the active history preview', + ); + } + return LiveDraftHistoryReuseResultDto( + draft: LiveDraftDto.fromJson(object['draft']), + updatedFields: Set.unmodifiable(updatedFields), + releasedLeases: _optionalReleasedLeases(object['releasedLeases']), + historyReuse: historyReuse, + locks: List.unmodifiable( + lockValues.map(LiveDraftLockDto.fromJson), + ), + ); + } + + final LiveDraftDto draft; + final Set updatedFields; + final List releasedLeases; + final LiveDraftHistoryReuseDto historyReuse; + final List locks; +} + final class LiveDraftPatchResultDto { const LiveDraftPatchResultDto({ required this.draft, required this.appliedClientSeq, required this.replayed, + this.releasedLeases = const [], }); factory LiveDraftPatchResultDto.fromJson(Object? json) { @@ -244,12 +540,14 @@ final class LiveDraftPatchResultDto { appliedClientSeq: _nonNegativeInteger(object, 'appliedClientSeq', minimum: 1), replayed: _boolean(object, 'replayed'), + releasedLeases: _optionalReleasedLeases(object['releasedLeases']), ); } final LiveDraftDto draft; final int appliedClientSeq; final bool replayed; + final List releasedLeases; } final class LiveDraftCommitResultDto { @@ -418,6 +716,16 @@ bool _boolean(JsonObject object, String field) { throw FormatException('$field must be a boolean'); } +List _optionalReleasedLeases(Object? value) { + if (value == null) return const []; + if (value is! List) { + throw const FormatException('releasedLeases must be an array'); + } + return List.unmodifiable( + value.map(LiveDraftReleasedLeaseDto.fromJson), + ); +} + DateTime _dateTime(JsonObject object, String field) { final value = object[field]; if (value is String) { diff --git a/lib/providers/collaboration_provider.dart b/lib/providers/collaboration_provider.dart index dbfe384..212df35 100644 --- a/lib/providers/collaboration_provider.dart +++ b/lib/providers/collaboration_provider.dart @@ -258,6 +258,121 @@ final class LiveDraftAtomicStateMerge { final Map baseRevisions; } +@visibleForTesting +final class LiveDraftLockRebaseProjection { + const LiveDraftLockRebaseProjection({ + required this.canonicalDraft, + required this.localFields, + required this.dirtyFields, + required this.baseRevisions, + required this.conflictedFields, + required this.generationChanged, + }); + + final LiveDraftDto canonicalDraft; + final LiveDraftFieldsDto localFields; + final Set dirtyFields; + final Map baseRevisions; + final Set conflictedFields; + final bool generationChanged; +} + +/// Rebases one locally dirty field to the canonical revision captured at the +/// instant its exclusive lease was granted. Unrelated dirty fields keep their +/// original conflict baselines. +@visibleForTesting +LiveDraftLockRebaseProjection projectLiveDraftLockAcquisition({ + required String acquiredField, + required LiveDraftDto currentDraft, + required LiveDraftDto incomingDraft, + required LiveDraftFieldsDto localFields, + required Set dirtyFields, + required Map baseRevisions, +}) { + if (!liveDraftFieldNames.contains(acquiredField)) { + throw ArgumentError.value(acquiredField, 'acquiredField'); + } + if (incomingDraft.sessionId != currentDraft.sessionId) { + throw const FormatException( + 'Live-draft lock snapshot belongs to another Session', + ); + } + if (incomingDraft.draftId != currentDraft.draftId) { + return LiveDraftLockRebaseProjection( + canonicalDraft: incomingDraft, + localFields: incomingDraft.fields, + dirtyFields: const {}, + baseRevisions: const {}, + conflictedFields: const {}, + generationChanged: true, + ); + } + if (incomingDraft.version < currentDraft.version) { + return LiveDraftLockRebaseProjection( + canonicalDraft: currentDraft, + localFields: localFields, + dirtyFields: Set.unmodifiable(dirtyFields), + baseRevisions: Map.unmodifiable(baseRevisions), + conflictedFields: const {}, + generationChanged: false, + ); + } + + final rebasedDirtyFields = Set.of(dirtyFields); + final rebasedBaseRevisions = Map.of(baseRevisions); + final conflictedFields = {}; + final values = {}; + for (final field in liveDraftFieldNames) { + if (!rebasedDirtyFields.contains(field)) { + values[field] = incomingDraft.fields[field]; + continue; + } + final localValue = localFields[field]; + values[field] = localValue; + if (localValue == incomingDraft.fields[field]) { + rebasedDirtyFields.remove(field); + rebasedBaseRevisions.remove(field); + } else if (field == acquiredField) { + final previousRevision = rebasedBaseRevisions[field] ?? + currentDraft.fieldRevisions[field] ?? + 0; + final incomingRevision = incomingDraft.fieldRevisions[field] ?? 0; + if (incomingRevision != previousRevision) { + // Granting the lease proves exclusivity only from this instant onward; + // it does not authorize overwriting a collaborator's change that was + // committed while the lock request crossed the network. + conflictedFields.add(field); + rebasedBaseRevisions[field] = previousRevision; + } else { + rebasedBaseRevisions[field] = incomingRevision; + } + } else { + rebasedBaseRevisions[field] = rebasedBaseRevisions[field] ?? + currentDraft.fieldRevisions[field] ?? + 0; + } + } + return LiveDraftLockRebaseProjection( + canonicalDraft: incomingDraft, + localFields: LiveDraftFieldsDto(values), + dirtyFields: Set.unmodifiable(rebasedDirtyFields), + baseRevisions: Map.unmodifiable(rebasedBaseRevisions), + conflictedFields: Set.unmodifiable(conflictedFields), + generationChanged: false, + ); +} + +@visibleForTesting +bool canReleaseIdleLiveDraftLease({ + required bool fieldDirty, + required LiveDraftLockDto? currentLock, + required String? expectedLeaseId, +}) { + return !fieldDirty && + currentLock != null && + (expectedLeaseId == null || currentLock.leaseId == expectedLeaseId); +} + /// Projects an atomic update into the local draft before the server responds. /// /// The original canonical revision is retained for fields that were already @@ -466,6 +581,212 @@ final class LiveDraftControlProjection { final bool changed; } +List? _liveDraftControlLocks(JsonObject message) { + if (!message.containsKey('locks')) return null; + final values = message['locks']; + if (values is! List) { + throw const FormatException('Live-draft control locks must be an array'); + } + return List.unmodifiable( + values.map(LiveDraftLockDto.fromJson), + ); +} + +bool _sameLiveDraftLocks( + List left, + List right, +) { + if (left.length != right.length) return false; + for (var index = 0; index < left.length; index += 1) { + final a = left[index]; + final b = right[index]; + if (a.leaseId != b.leaseId || + a.field != b.field || + a.userId != b.userId || + a.deviceId != b.deviceId || + a.expiresAt != b.expiresAt) { + return false; + } + } + return true; +} + +/// Explicit history reuse is the one remote update allowed to replace local +/// edits in its declared station-detail fields. Ordinary member updates retain +/// the standard dirty-field preservation behavior. +@visibleForTesting +LiveDraftControlProjection applyLiveDraftHistoryReuseProjection({ + required LiveDraftControlProjection projection, + required LiveDraftDto canonicalDraft, + required LiveDraftHistoryReuseDto historyReuse, + List? locks, +}) { + if (projection.snapshot.draft.sessionId != canonicalDraft.sessionId || + projection.snapshot.draft.draftId != canonicalDraft.draftId) { + throw const FormatException( + 'History reuse belongs to another live-draft generation', + ); + } + final dirtyFields = Set.of(projection.dirtyFields) + ..removeAll(historyReuse.affectedFields); + final baseRevisions = Map.of(projection.baseRevisions) + ..removeWhere((field, _) => historyReuse.affectedFields.contains(field)); + return LiveDraftControlProjection( + snapshot: LiveDraftSnapshotDto( + draft: canonicalDraft, + locks: locks ?? projection.snapshot.locks, + currentOrdinal: projection.snapshot.currentOrdinal, + totalRecords: projection.snapshot.totalRecords, + previousRecord: projection.snapshot.previousRecord, + ), + localFields: LiveDraftFieldsDto({ + for (final field in liveDraftFieldNames) + field: historyReuse.affectedFields.contains(field) + ? canonicalDraft.fields[field] + : projection.localFields[field], + }), + dirtyFields: Set.unmodifiable(dirtyFields), + baseRevisions: Map.unmodifiable(baseRevisions), + changed: true, + ); +} + +/// A member-control acknowledgement for a PATCH whose HTTP response may have +/// been lost after the server committed it. +@visibleForTesting +final class LiveDraftPatchControlAcknowledgement { + const LiveDraftPatchControlAcknowledgement({ + required this.draft, + required this.releasedLeases, + }); + + final LiveDraftDto draft; + final List releasedLeases; +} + +/// Mirrors the server's canonical live-draft field normalization so a WebSocket +/// acknowledgement still matches when the accepted value was trimmed or an +/// identifier was uppercased before persistence. +@visibleForTesting +String canonicalLiveDraftPatchAckValue(String field, String? value) { + if (!liveDraftFieldNames.contains(field)) { + throw ArgumentError.value(field, 'field'); + } + final normalized = value?.trim() ?? ''; + return field == 'controller' || field == 'callsign' + ? normalized.toUpperCase() + : normalized; +} + +/// Returns whether a draft-generation transition would discard local input +/// that is neither present in the current canonical draft nor already +/// represented by an accepted record. +@visibleForTesting +bool hasUnpreservedLiveDraftChanges({ + required LiveDraftDto currentDraft, + required LiveDraftFieldsDto localFields, + required Set dirtyFields, + LiveDraftFieldsDto? acceptedFields, +}) { + for (final field in dirtyFields) { + if (!liveDraftFieldNames.contains(field)) continue; + final localValue = canonicalLiveDraftPatchAckValue( + field, + localFields[field], + ); + final currentValue = canonicalLiveDraftPatchAckValue( + field, + currentDraft.fields[field], + ); + if (localValue == currentValue) continue; + if (acceptedFields != null && + localValue == + canonicalLiveDraftPatchAckValue(field, acceptedFields[field])) { + continue; + } + return true; + } + return false; +} + +LiveDraftFieldsDto _liveDraftFieldsFromCommittedRecord( + CollaborationLogDto record, +) => + LiveDraftFieldsDto({ + 'time': record.time.toUtc().toIso8601String(), + 'controller': record.controller, + 'callsign': record.callsign, + 'rstSent': record.rstSent ?? '', + 'rstRcvd': record.rstRcvd ?? '', + 'qth': record.qth ?? '', + 'device': record.device ?? '', + 'power': record.power ?? '', + 'antenna': record.antenna ?? '', + 'height': record.height ?? '', + 'remarks': record.remarks ?? '', + }); + +/// Matches only a control emitted for this exact device/client sequence and +/// exact field mutation. New servers identify [updatedFields] explicitly; +/// older servers remain compatible through value plus monotonic-revision +/// matching against the complete canonical draft. +@visibleForTesting +LiveDraftPatchControlAcknowledgement? matchLiveDraftPatchControlAck({ + required JsonObject message, + required String sessionId, + required String deviceId, + required int clientSeq, + required String draftId, + required Map expectedValues, + required Map expectedRevisions, +}) { + if (message['type'] != 'liveDraft.updated' || + message['sessionId'] != sessionId || + message['deviceId'] != deviceId || + message['clientSeq'] != clientSeq) { + return null; + } + try { + final updatedFieldsValue = message['updatedFields'] ?? message['fields']; + if (updatedFieldsValue != null) { + if (updatedFieldsValue is! List || + updatedFieldsValue.any( + (value) => value is! String || !liveDraftFieldNames.contains(value), + )) { + return null; + } + final updatedFields = updatedFieldsValue.cast().toSet(); + if (!updatedFields.containsAll(expectedValues.keys)) return null; + } + final incoming = LiveDraftDto.fromJson(message['draft']); + if (incoming.sessionId != sessionId || incoming.draftId != draftId) { + return null; + } + for (final entry in expectedValues.entries) { + final expectedRevision = expectedRevisions[entry.key]; + if (expectedRevision == null || + incoming.fields[entry.key] != entry.value || + (incoming.fieldRevisions[entry.key] ?? 0) <= expectedRevision) { + return null; + } + } + final releasedValue = message['releasedLeases']; + if (releasedValue != null && releasedValue is! List) return null; + final releasedValues = + releasedValue is List ? releasedValue : const []; + return LiveDraftPatchControlAcknowledgement( + draft: incoming, + releasedLeases: releasedValues.isEmpty + ? const [] + : List.unmodifiable( + releasedValues.map(LiveDraftReleasedLeaseDto.fromJson), + ), + ); + } on FormatException { + return null; + } +} + /// Applies a complete live-draft control payload with draft-generation and /// version gating. Older controls from the same generation are ignored. @visibleForTesting @@ -504,6 +825,8 @@ LiveDraftControlProjection applyLiveDraftControlMessage({ } final currentDraft = currentSnapshot.draft; final sameGeneration = currentDraft.draftId == incoming.draftId; + final locksChanged = + locks != null && !_sameLiveDraftLocks(currentSnapshot.locks, locks); if (!sameGeneration && !allowGenerationChange) { throw const FormatException( 'Live-draft control cannot prove a draft generation change', @@ -511,7 +834,8 @@ LiveDraftControlProjection applyLiveDraftControlMessage({ } if (sameGeneration && incoming.version <= currentDraft.version && - !discardLocalState) { + !discardLocalState && + !locksChanged) { return LiveDraftControlProjection( snapshot: currentSnapshot, localFields: currentLocalFields, @@ -561,7 +885,10 @@ LiveDraftControlProjection applyLiveDraftControlMessage({ switch (type) { case 'liveDraft.updated': - return adoptDraft(LiveDraftDto.fromJson(message['draft'])); + return adoptDraft( + LiveDraftDto.fromJson(message['draft']), + locks: _liveDraftControlLocks(message), + ); case 'liveDraft.cleared': final nextDraft = LiveDraftDto.fromJson(message['nextDraft']); final terminalValue = message['terminal']; @@ -871,29 +1198,97 @@ final class ResettableLiveDraftSerialExecutor { } typedef LiveDraftAtomicAttempt = Future Function(); -typedef LiveDraftAtomicConflictRebaser = Future Function(); +typedef LiveDraftAtomicConflictRebaser = Future Function( + ServerApiException conflict, +); +typedef LiveDraftCommitAttempt = Future Function(int attempt); +typedef LiveDraftCommitRaceRecoverer = Future Function( + ServerApiException conflict, + int attempt, +); -/// Retries an explicit atomic fill once after rebasing the stale field/version -/// baseline. Lock and sequence conflicts retain their existing handling. +/// Retries an explicit atomic fill after rebasing a stale field/version +/// baseline. A few bounded retries absorb long-RTT races without weakening the +/// server's compare-and-swap protection. @visibleForTesting Future executeLiveDraftAtomicPatchWithRebaseRetry({ required LiveDraftAtomicAttempt attempt, required LiveDraftAtomicConflictRebaser rebase, + int maxAttempts = 4, }) async { - try { - return await attempt(); - } on ServerApiException catch (error) { - if (!{ - 'LIVE_DRAFT_FIELD_CONFLICT', - 'LIVE_DRAFT_VERSION_CONFLICT', - }.contains(error.code)) { - rethrow; + if (maxAttempts < 1) { + throw ArgumentError.value(maxAttempts, 'maxAttempts', 'must be positive'); + } + for (var attemptNumber = 1;; attemptNumber += 1) { + try { + return await attempt(); + } on ServerApiException catch (error) { + if (attemptNumber >= maxAttempts || + !{ + 'LIVE_DRAFT_FIELD_CONFLICT', + 'LIVE_DRAFT_VERSION_CONFLICT', + }.contains(error.code)) { + rethrow; + } + await rebase(error); + } + } +} + +/// Retries a commit only for the two races that can settle without operator +/// input: a stale canonical version and a collaborator's short-lived lease. +/// The caller owns the recovery policy so production can back off while tests +/// remain deterministic. +@visibleForTesting +Future executeLiveDraftCommitWithRaceRecovery({ + required LiveDraftCommitAttempt attempt, + required LiveDraftCommitRaceRecoverer recover, + int maxAttempts = 4, +}) async { + if (maxAttempts < 1) { + throw ArgumentError.value(maxAttempts, 'maxAttempts', 'must be positive'); + } + for (var attemptNumber = 1;; attemptNumber += 1) { + try { + return await attempt(attemptNumber); + } on ServerApiException catch (error) { + final recoverableRace = { + 'LIVE_DRAFT_VERSION_CONFLICT', + 'LIVE_DRAFT_BUSY', + }.contains(error.code); + if (!recoverableRace || attemptNumber >= maxAttempts) rethrow; + await recover(error, attemptNumber); } - await rebase(); - return attempt(); } } +final class _LiveDraftFieldFlushBatch { + _LiveDraftFieldFlushBatch(this.epoch); + + final int epoch; + final Completer completer = Completer(); +} + +final class _PendingLiveDraftPatchAck { + _PendingLiveDraftPatchAck({ + required this.sessionId, + required this.deviceId, + required this.clientSeq, + required this.draftId, + required this.expectedValues, + required this.expectedRevisions, + }); + + final String sessionId; + final String deviceId; + final int clientSeq; + final String draftId; + final Map expectedValues; + final Map expectedRevisions; + final Completer completer = + Completer(); +} + /// Acquires every required field lease before issuing exactly one PATCH. /// /// Locks already owned by the caller are retained. Locks acquired by this @@ -936,6 +1331,7 @@ Future executeLiveDraftAtomicPatch({ liveDraftFieldNames.where(values.containsKey).toList(growable: false); final acquiredHere = {}; final leases = {}; + final serverReleasedLeaseIds = {}; final currentTime = now ?? DateTime.now(); try { for (final field in orderedFields) { @@ -948,9 +1344,12 @@ Future executeLiveDraftAtomicPatch({ continue; } final acquired = await acquireLock(field); - assertCurrent?.call(); leases[field] = acquired; acquiredHere[field] = acquired; + // Register cleanup before checking context. The request can complete + // after a binding switch/dispose; that late lease still needs a bounded + // best-effort release. + assertCurrent?.call(); } final updates = [ @@ -967,6 +1366,9 @@ Future executeLiveDraftAtomicPatch({ try { assertCurrent?.call(); result = await sendPatch(clientSeq, updates); + serverReleasedLeaseIds.addAll( + result.releasedLeases.map((lease) => lease.leaseId), + ); assertCurrent?.call(); } on ServerApiException catch (error) { assertCurrent?.call(); @@ -977,12 +1379,18 @@ Future executeLiveDraftAtomicPatch({ clientSeq = expected; assertCurrent?.call(); result = await sendPatch(clientSeq, updates); + serverReleasedLeaseIds.addAll( + result.releasedLeases.map((lease) => lease.leaseId), + ); assertCurrent?.call(); } else if (error.code == 'LIVE_DRAFT_CLIENT_SEQ_REUSED') { onClientSeqChanged(clientSeq); clientSeq += 1; assertCurrent?.call(); result = await sendPatch(clientSeq, updates); + serverReleasedLeaseIds.addAll( + result.releasedLeases.map((lease) => lease.leaseId), + ); assertCurrent?.call(); } else { rethrow; @@ -1001,7 +1409,9 @@ Future executeLiveDraftAtomicPatch({ } finally { for (final field in orderedFields.reversed) { final lock = acquiredHere[field]; - if (lock == null) continue; + if (lock == null || serverReleasedLeaseIds.contains(lock.leaseId)) { + continue; + } try { await releaseLock(field, lock); } catch (_) { @@ -1019,6 +1429,21 @@ int? _expectedLiveDraftClientSeq(ServerApiException error) { return expected is int && expected > 0 ? expected : null; } +@visibleForTesting +LiveDraftDto? liveDraftCanonicalFromConflict( + ServerApiException error, { + required String sessionId, +}) { + final details = error.details; + if (details is! Map || details['draft'] == null) return null; + try { + final draft = LiveDraftDto.fromJson(details['draft']); + return draft.sessionId == sessionId ? draft : null; + } on FormatException { + return null; + } +} + class CollaborationProvider with ChangeNotifier { CollaborationProvider({ CollaborationReplicaPort? replica, @@ -1094,7 +1519,12 @@ class CollaborationProvider with ChangeNotifier { int _liveDraftGeneration = 0; final ResettableLiveDraftSerialExecutor _liveDraftSerial = ResettableLiveDraftSerialExecutor(); + final Map _liveDraftFieldFlushBatches = + {}; + final Map _pendingLiveDraftPatchAcks = + {}; Timer? _liveDraftRenewalTimer; + Timer? _liveDraftHistoryPreviewExpiryTimer; Timer? _catalogTimer; Future? _catalogOperation; String? _catalogScope; @@ -1127,6 +1557,13 @@ class CollaborationProvider with ChangeNotifier { Map _liveDraftBaseRevisions = const {}; Map _ownedLiveDraftLocks = const {}; List _offlineRecords = const []; + LiveDraftHistoryPreviewDto? _liveDraftHistoryPreview; + int _liveDraftHistoryReuseEpoch = 0; + Set _liveDraftHistoryReuseAffectedFields = const {}; + String? _lastLiveDraftHistoryReuseToken; + bool _liveDraftHistoryPreviewUnavailable = false; + int _liveDraftLocalEditRevision = 0; + Map _liveDraftLocalFieldEditRevisions = const {}; int _liveDraftClientSeq = 0; bool _liveDraftLoading = false; String? _liveDraftErrorCode; @@ -1179,6 +1616,20 @@ class CollaborationProvider with ChangeNotifier { _liveDraftSnapshot?.locks ?? const []; Map get ownedLiveDraftLocks => Map.unmodifiable(_ownedLiveDraftLocks); + bool isLiveDraftFieldDirty(String field) => + _dirtyLiveDraftFields.contains(field); + LiveDraftHistoryPreviewDto? get liveDraftHistoryPreview { + final preview = _liveDraftHistoryPreview; + return preview != null && preview.expiresAt.isAfter(DateTime.now()) + ? preview + : null; + } + + bool get liveDraftHistoryPreviewOwnedHere => + liveDraftHistoryPreview?.deviceId == _deviceId; + int get liveDraftHistoryReuseEpoch => _liveDraftHistoryReuseEpoch; + Set get liveDraftHistoryReuseAffectedFields => + _liveDraftHistoryReuseAffectedFields; List get offlineRecords => _offlineRecords; bool get liveDraftLoading => _liveDraftLoading; String? get liveDraftErrorCode => _liveDraftErrorCode; @@ -2108,6 +2559,25 @@ class CollaborationProvider with ChangeNotifier { ); }); + void _observeLiveDraftPatchControlAck(JsonObject message) { + final seq = message['clientSeq']; + if (seq is! int) return; + final pending = _pendingLiveDraftPatchAcks[seq]; + if (pending == null || pending.completer.isCompleted) return; + final acknowledgement = matchLiveDraftPatchControlAck( + message: message, + sessionId: pending.sessionId, + deviceId: pending.deviceId, + clientSeq: pending.clientSeq, + draftId: pending.draftId, + expectedValues: pending.expectedValues, + expectedRevisions: pending.expectedRevisions, + ); + if (acknowledgement != null) { + pending.completer.complete(acknowledgement); + } + } + Future _applyLiveDraftControl(JsonObject message) => _serializeLiveDraft(() async { final context = _tryLiveDraftContext(requireEdit: false); @@ -2127,8 +2597,17 @@ class CollaborationProvider with ChangeNotifier { final terminalClear = message['type'] == 'liveDraft.cleared' && message['terminal'] == true; - final snapshot = _liveDraftSnapshot; - final localFields = _localLiveDraftFields; + var snapshot = _liveDraftSnapshot; + var localFields = _localLiveDraftFields; + if (snapshot == null || localFields == null) { + bool contextIsCurrent() => _isLiveDraftContextCurrent(context); + await _hydrateLiveDraftCacheIfEmpty(binding, contextIsCurrent); + if (!contextIsCurrent()) return; + await _loadLiveDraftOfflineRecords(binding, contextIsCurrent); + if (!contextIsCurrent()) return; + snapshot = _liveDraftSnapshot; + localFields = _localLiveDraftFields; + } if (snapshot == null || localFields == null) { if (terminalClear) { final nextDraft = LiveDraftDto.fromJson(message['nextDraft']); @@ -2153,6 +2632,8 @@ class CollaborationProvider with ChangeNotifier { _clearOwnedLiveDraftLocks(); _liveDraftSnapshot = null; _localLiveDraftFields = null; + _liveDraftHistoryPreview = null; + _cancelLiveDraftHistoryPreviewExpiry(); _automaticLiveDraftTime = null; _dirtyLiveDraftFields = const {}; _liveDraftBaseRevisions = const {}; @@ -2172,7 +2653,7 @@ class CollaborationProvider with ChangeNotifier { return; } - late final LiveDraftControlProjection projection; + late LiveDraftControlProjection projection; try { projection = applyLiveDraftControlMessage( currentSnapshot: snapshot, @@ -2188,7 +2669,105 @@ class CollaborationProvider with ChangeNotifier { await recoverFromCanonicalSnapshot(); return; } - if (!projection.changed) return; + + var historyPreviewSpecified = false; + var nextHistoryPreview = _liveDraftHistoryPreview; + LiveDraftHistoryReuseDto? historyReuse; + LiveDraftDto? historyReuseDraft; + if (message['type'] == 'liveDraft.updated') { + try { + final incoming = LiveDraftDto.fromJson(message['draft']); + final controlIsCurrent = + incoming.draftId == snapshot.draft.draftId && + incoming.version >= snapshot.draft.version; + if (controlIsCurrent && message.containsKey('historyPreview')) { + historyPreviewSpecified = true; + final value = message['historyPreview']; + if (value == null) { + nextHistoryPreview = null; + } else { + final preview = LiveDraftHistoryPreviewDto.fromJson(value); + if (preview.draftId != incoming.draftId || + preview.callsign != incoming.fields['callsign']) { + throw const FormatException( + 'History preview does not match the canonical draft', + ); + } + nextHistoryPreview = + preview.expiresAt.isAfter(DateTime.now()) ? preview : null; + } + } + final reuseValue = message['historyReuse']; + if (controlIsCurrent && reuseValue != null) { + final candidateReuse = + LiveDraftHistoryReuseDto.fromJson(reuseValue); + final token = '${incoming.sessionId}:${incoming.draftId}:' + '${incoming.version}:${candidateReuse.previewId}:' + '${candidateReuse.candidateId}'; + if (_lastLiveDraftHistoryReuseToken != token) { + historyReuse = candidateReuse; + historyReuseDraft = incoming; + projection = applyLiveDraftHistoryReuseProjection( + projection: projection, + canonicalDraft: incoming, + historyReuse: candidateReuse, + locks: _liveDraftControlLocks(message), + ); + historyPreviewSpecified = true; + nextHistoryPreview = null; + } + } + } on FormatException { + await recoverFromCanonicalSnapshot(); + return; + } + } + final historyPreviewChanged = historyPreviewSpecified && + nextHistoryPreview?.previewId != + _liveDraftHistoryPreview?.previewId; + if (!projection.changed && + !historyPreviewChanged && + historyReuse == null) { + return; + } + + final generationChanged = + projection.snapshot.draft.draftId != snapshot.draft.draftId; + var displacedLocalDraft = false; + if (generationChanged) { + final type = message['type']; + LiveDraftFieldsDto? acceptedFields; + if (type == 'liveDraft.committed') { + final record = projection.snapshot.previousRecord; + if (record != null) { + acceptedFields = _liveDraftFieldsFromCommittedRecord(record); + } + } + try { + displacedLocalDraft = + await _preserveCurrentDisplacedLiveDraftUntilStable( + context: context, + snapshot: snapshot, + acceptedFields: acceptedFields, + ); + } catch (error, stackTrace) { + AppLogger.instance.log( + AppLogLevel.error, + 'Could not preserve local input before a draft generation change', + source: 'CollaborationProvider', + error: error, + stackTrace: stackTrace, + ); + if (_isLiveDraftContextCurrent(context)) { + _setLiveDraftError( + 'LIVE_DRAFT_LOCAL_RECOVERY_SAVE_FAILED', + '草稿已在服务器切换,但本机输入暂时无法安全保存;已保留当前表单,请先不要关闭应用。', + ); + } + return; + } + if (!_isLiveDraftContextCurrent(context)) return; + } final draftChanged = projection.snapshot.draft.draftId != snapshot.draft.draftId || @@ -2197,9 +2776,28 @@ class CollaborationProvider with ChangeNotifier { _localLiveDraftFields = projection.localFields; _dirtyLiveDraftFields = projection.dirtyFields; _liveDraftBaseRevisions = projection.baseRevisions; + if (historyPreviewSpecified) { + _liveDraftHistoryPreview = nextHistoryPreview; + if (nextHistoryPreview == null) { + _cancelLiveDraftHistoryPreviewExpiry(); + } else { + _scheduleLiveDraftHistoryPreviewExpiry(nextHistoryPreview); + } + } + if (historyReuse != null && historyReuseDraft != null) { + _recordLiveDraftHistoryReuse(historyReuse, historyReuseDraft); + } + if (generationChanged) { + _liveDraftHistoryPreview = null; + _cancelLiveDraftHistoryPreviewExpiry(); + } if (terminalClear) _automaticLiveDraftTime = null; _reconcileOwnedLiveDraftLocks(projection.snapshot.locks); - _clearLiveDraftError(); + if (displacedLocalDraft) { + _setDisplacedLiveDraftWarning(); + } else { + _clearLiveDraftError(); + } _safeNotify(); if (draftChanged) { await _persistLiveDraftState(_guardFor(context)); @@ -2216,16 +2814,39 @@ class CollaborationProvider with ChangeNotifier { if (current != null && current.expiresAt.isAfter(DateTime.now())) { return current; } - final lock = await context.api.acquireLiveDraftLock( + final acquisition = await context.api.acquireLiveDraftLockWithDraft( sessionId: context.binding.sessionId, field: field, deviceId: context.deviceId, ); _assertLiveDraftContextCurrent(context); + final lock = acquisition.lock; + late final ({bool revisionConflict, bool displacedLocalDraft}) adoption; + try { + adoption = await _adoptLiveDraftAtLockAcquisition( + field, + acquisition.draft, + context, + ); + } catch (error, stackTrace) { + await _releaseTemporaryLiveDraftField(field, lock, context); + Error.throwWithStackTrace(error, stackTrace); + } _ownedLiveDraftLocks = {..._ownedLiveDraftLocks, field: lock}; _replaceLiveDraftLock(lock); + if (adoption.revisionConflict) { + await _releaseTemporaryLiveDraftField(field, lock, context); + throw _liveDraftLockAcquisitionConflict( + field, + acquisition.draft!, + ); + } _ensureLiveDraftRenewalTimer(); - _clearLiveDraftError(); + if (adoption.displacedLocalDraft) { + _setDisplacedLiveDraftWarning(); + } else { + _clearLiveDraftError(); + } _safeNotify(); return lock; }); @@ -2244,11 +2865,7 @@ class CollaborationProvider with ChangeNotifier { _safeNotify(); if (context == null) return; try { - await context.api.releaseLiveDraftLock( - sessionId: context.binding.sessionId, - leaseId: lock.leaseId, - deviceId: context.deviceId, - ); + await _releaseLiveDraftLockWithBoundedRetry(lock, context); if (!_isLiveDraftContextCurrent(context)) return; } on ServerApiException catch (error) { if (!_isLiveDraftContextCurrent(context)) return; @@ -2256,37 +2873,185 @@ class CollaborationProvider with ChangeNotifier { .contains(error.code)) { _setLiveDraftError(error.code, error.message); } + } on TimeoutException catch (error) { + if (_isLiveDraftContextCurrent(context)) { + _setLiveDraftError('LIVE_DRAFT_RELEASE_TIMEOUT', error.toString()); + } } }); - Future updateLiveDraftField(String field, String value) { + /// Releases an idle field lease only after re-checking serialized state. + /// A keystroke can arrive after a successful flush but before its cleanup + /// enters the queue; in that case the newly dirty field keeps the lease. + Future releaseLiveDraftFieldIfClean( + String field, { + String? expectedLeaseId, + }) => + _serializeLiveDraft(() async { + final lock = _ownedLiveDraftLocks[field]; + if (!canReleaseIdleLiveDraftLease( + fieldDirty: _dirtyLiveDraftFields.contains(field), + currentLock: lock, + expectedLeaseId: expectedLeaseId, + )) { + return; + } + final releasableLock = lock!; + final context = _tryLiveDraftContext(requireEdit: false); + _ownedLiveDraftLocks = Map.of(_ownedLiveDraftLocks)..remove(field); + _removeLiveDraftLock(releasableLock.leaseId); + if (_ownedLiveDraftLocks.isEmpty) { + _liveDraftRenewalTimer?.cancel(); + _liveDraftRenewalTimer = null; + } + _safeNotify(); + if (context == null) return; + try { + await _releaseLiveDraftLockWithBoundedRetry( + releasableLock, + context, + ); + } on ServerApiException catch (error) { + if (!_isLiveDraftContextCurrent(context)) return; + if (!{'LIVE_DRAFT_LOCK_NOT_FOUND', 'LIVE_DRAFT_LOCK_EXPIRED'} + .contains(error.code)) { + _setLiveDraftError(error.code, error.message); + } + } on TimeoutException catch (error) { + if (_isLiveDraftContextCurrent(context)) { + _setLiveDraftError('LIVE_DRAFT_RELEASE_TIMEOUT', error.toString()); + } + } + }); + + /// Stages the latest local value immediately without waiting for the + /// collaboration round trip. The form calls this for every local edit, then + /// separately debounces [flushLiveDraftField]. + void _markLiveDraftLocalFieldsEdited(Iterable fields) { + final editedFields = fields.where(liveDraftFieldNames.contains).toSet(); + if (editedFields.isEmpty) return; + _liveDraftLocalEditRevision += 1; + final revision = _liveDraftLocalEditRevision; + _liveDraftLocalFieldEditRevisions = { + ..._liveDraftLocalFieldEditRevisions, + for (final field in editedFields) field: revision, + }; + } + + void stageLiveDraftField(String field, String value) { if (!liveDraftFieldNames.contains(field)) { - return Future.error(ArgumentError.value(field, 'field')); + throw ArgumentError.value(field, 'field'); } if (!canEditLiveDraft) { - return Future.error(StateError('LIVE_DRAFT_READ_ONLY')); + throw StateError('LIVE_DRAFT_READ_ONLY'); } final normalizedValue = field == 'time' ? _normalizeLiveDraftTime(value) : value; final current = liveDraftFields ?? LiveDraftFieldsDto.empty(); if (current[field] == normalizedValue && !_dirtyLiveDraftFields.contains(field)) { - return Future.value(); + return; } _localLiveDraftFields = current.withField(field, normalizedValue); - if (!_dirtyLiveDraftFields.contains(field)) { + _markLiveDraftLocalFieldsEdited([field]); + final canonicalValue = _liveDraftSnapshot?.draft.fields[field] ?? ''; + if (normalizedValue == canonicalValue) { + _dirtyLiveDraftFields = Set.of(_dirtyLiveDraftFields)..remove(field); + _liveDraftBaseRevisions = Map.of(_liveDraftBaseRevisions)..remove(field); + } else if (!_dirtyLiveDraftFields.contains(field)) { _liveDraftBaseRevisions = { ..._liveDraftBaseRevisions, field: _liveDraftSnapshot?.draft.fieldRevisions[field] ?? 0, }; + _dirtyLiveDraftFields = {..._dirtyLiveDraftFields, field}; + } else { + _dirtyLiveDraftFields = {..._dirtyLiveDraftFields, field}; } - _dirtyLiveDraftFields = {..._dirtyLiveDraftFields, field}; _safeNotify(); - return _serializeLiveDraft(() => _flushLiveDraftField(field)); } - Future updateLiveDraftFieldsAtomically(Map updates) { - return _queueLiveDraftFieldsAtomicUpdate( + Future updateLiveDraftField(String field, String value) { + try { + stageLiveDraftField(field, value); + } catch (error, stackTrace) { + return Future.error(error, stackTrace); + } + return flushLiveDraftField(field); + } + + /// Flushes the most recent staged value. Repeated calls for one field share + /// one pending batch; edits made while a PATCH is in flight are folded into + /// the next PATCH instead of creating an unbounded high-latency queue. + Future flushLiveDraftField(String field) { + if (!liveDraftFieldNames.contains(field)) { + return Future.error(ArgumentError.value(field, 'field')); + } + if (!_dirtyLiveDraftFields.contains(field)) { + return Future.value(); + } + final existing = _liveDraftFieldFlushBatches[field]; + if (existing != null && existing.epoch == _stateEpoch) { + return existing.completer.future; + } + if (existing != null && !existing.completer.isCompleted) { + existing.completer.completeError( + StateError('LIVE_DRAFT_CONTEXT_CHANGED'), + ); + } + final batch = _LiveDraftFieldFlushBatch(_stateEpoch); + _liveDraftFieldFlushBatches[field] = batch; + _scheduleLiveDraftFieldFlush(field, batch); + return batch.completer.future; + } + + void _scheduleLiveDraftFieldFlush( + String field, + _LiveDraftFieldFlushBatch batch, + ) { + final operation = _serializeLiveDraft(() => _flushLiveDraftField(field)); + unawaited( + operation.then( + (_) { + if (!identical(_liveDraftFieldFlushBatches[field], batch)) { + if (!batch.completer.isCompleted) { + batch.completer.completeError( + StateError('LIVE_DRAFT_CONTEXT_CHANGED'), + ); + } + return; + } + if (batch.epoch != _stateEpoch) { + _liveDraftFieldFlushBatches.remove(field); + if (!batch.completer.isCompleted) { + batch.completer.completeError( + StateError('LIVE_DRAFT_CONTEXT_CHANGED'), + ); + } + return; + } + if (_dirtyLiveDraftFields.contains(field)) { + // Append the next latest-value PATCH behind controls and work that + // arrived while this request was in flight, preventing starvation. + _scheduleLiveDraftFieldFlush(field, batch); + return; + } + _liveDraftFieldFlushBatches.remove(field); + if (!batch.completer.isCompleted) batch.completer.complete(); + }, + onError: (Object error, StackTrace stackTrace) { + if (identical(_liveDraftFieldFlushBatches[field], batch)) { + _liveDraftFieldFlushBatches.remove(field); + } + if (!batch.completer.isCompleted) { + batch.completer.completeError(error, stackTrace); + } + }, + ), + ); + } + + Future updateLiveDraftFieldsAtomically(Map updates) { + return _queueLiveDraftFieldsAtomicUpdate( updates, retryRevisionConflicts: true, ); @@ -2305,6 +3070,264 @@ class CollaborationProvider with ChangeNotifier { ); } + /// Publishes the local callsign-history dropdown so other scribes can see + /// the same candidates. Unsupported older servers return `null`; callers + /// should keep their local dropdown and use the legacy atomic reuse path. + Future publishLiveDraftHistoryPreview({ + required String callsign, + required List candidates, + }) => + _serializeLiveDraft(() async { + if (_liveDraftHistoryPreviewUnavailable || candidates.isEmpty) { + return null; + } + final context = _requireLiveDraftContext(requireEdit: true); + final snapshot = _liveDraftSnapshot; + final normalizedCallsign = callsign.trim().toUpperCase(); + if (snapshot == null || + normalizedCallsign.isEmpty || + snapshot.draft.fields['callsign'] != normalizedCallsign || + liveDraftFields?['callsign'].trim().toUpperCase() != + normalizedCallsign) { + return null; + } + var lock = _ownedLiveDraftLocks['callsign']; + if (lock == null || !lock.expiresAt.isAfter(DateTime.now())) { + lock = await _acquireLiveDraftFieldInternal('callsign', context); + _assertLiveDraftContextCurrent(context); + final refreshedSnapshot = _liveDraftSnapshot; + if (refreshedSnapshot == null || + refreshedSnapshot.draft.draftId != snapshot.draft.draftId || + refreshedSnapshot.draft.fields['callsign'] != + normalizedCallsign || + liveDraftFields?['callsign'].trim().toUpperCase() != + normalizedCallsign) { + if (!_dirtyLiveDraftFields.contains('callsign')) { + await _releaseTemporaryLiveDraftField( + 'callsign', + lock, + context, + ); + } + return null; + } + } + try { + final result = await context.api.publishLiveDraftHistoryPreview( + sessionId: context.binding.sessionId, + deviceId: context.deviceId, + leaseId: lock.leaseId, + draftId: snapshot.draft.draftId, + callsign: normalizedCallsign, + candidates: candidates, + ); + _assertLiveDraftContextCurrent(context); + final current = _liveDraftSnapshot; + final currentFields = _localLiveDraftFields; + if (current == null || + currentFields == null || + current.draft.draftId != result.draft.draftId || + currentFields['callsign'].trim().toUpperCase() != + normalizedCallsign) { + try { + await context.api.clearLiveDraftHistoryPreview( + sessionId: context.binding.sessionId, + deviceId: context.deviceId, + previewId: result.historyPreview.previewId, + ); + } catch (_) { + // A changed local query must not be blocked by cleanup failure. + } + if (!_dirtyLiveDraftFields.contains('callsign')) { + await _releaseTemporaryLiveDraftField( + 'callsign', + lock, + context, + ); + } + return null; + } + final canonical = current.draft.version > result.draft.version + ? current.draft + : result.draft; + _liveDraftSnapshot = LiveDraftSnapshotDto( + draft: canonical, + locks: result.locks, + currentOrdinal: current.currentOrdinal, + totalRecords: current.totalRecords, + previousRecord: current.previousRecord, + ); + _localLiveDraftFields = LiveDraftFieldsDto({ + for (final field in liveDraftFieldNames) + field: _dirtyLiveDraftFields.contains(field) + ? currentFields[field] + : canonical.fields[field], + }); + _liveDraftHistoryPreview = result.historyPreview; + _scheduleLiveDraftHistoryPreviewExpiry(result.historyPreview); + _reconcileOwnedLiveDraftLocks(result.locks); + _safeNotify(); + return result.historyPreview; + } on ServerApiException catch (error) { + if (_isUnsupportedLiveDraftHistoryPreviewError(error)) { + _liveDraftHistoryPreviewUnavailable = true; + if (!_dirtyLiveDraftFields.contains('callsign')) { + await _releaseTemporaryLiveDraftField( + 'callsign', + lock, + context, + ); + } + return null; + } + if (error.code == 'LIVE_DRAFT_HISTORY_PREVIEW_STALE') { + if (!_dirtyLiveDraftFields.contains('callsign')) { + await _releaseTemporaryLiveDraftField( + 'callsign', + lock, + context, + ); + } + return null; + } + if (!_dirtyLiveDraftFields.contains('callsign')) { + await _releaseTemporaryLiveDraftField('callsign', lock, context); + } + rethrow; + } catch (error, stackTrace) { + if (!_dirtyLiveDraftFields.contains('callsign')) { + await _releaseTemporaryLiveDraftField('callsign', lock, context); + } + Error.throwWithStackTrace(error, stackTrace); + } + }); + + Future clearLiveDraftHistoryPreview({String? expectedPreviewId}) => + _serializeLiveDraft(() async { + final preview = _liveDraftHistoryPreview; + if (preview == null || + preview.deviceId != _deviceId || + (expectedPreviewId != null && + preview.previewId != expectedPreviewId)) { + return; + } + _liveDraftHistoryPreview = null; + _cancelLiveDraftHistoryPreviewExpiry(); + _safeNotify(); + if (_liveDraftHistoryPreviewUnavailable) return; + final context = _tryLiveDraftContext(requireEdit: false); + if (context == null) return; + try { + await context.api.clearLiveDraftHistoryPreview( + sessionId: context.binding.sessionId, + deviceId: context.deviceId, + previewId: preview.previewId, + ); + } on ServerApiException catch (error) { + if (_isUnsupportedLiveDraftHistoryPreviewError(error)) { + _liveDraftHistoryPreviewUnavailable = true; + return; + } + if (error.code == 'LIVE_DRAFT_HISTORY_PREVIEW_STALE') return; + rethrow; + } + }); + + /// Selects a preview candidate through the server's atomic history-reuse + /// route. Returns false when an old server or an expired preview requires the + /// caller to fall back to the legacy optimistic multi-field PATCH. + Future selectLiveDraftHistoryCandidate({ + required String previewId, + required String candidateId, + }) => + _serializeLiveDraft(() async { + if (_liveDraftHistoryPreviewUnavailable) return false; + final preview = _liveDraftHistoryPreview; + if (preview == null || + preview.previewId != previewId || + preview.deviceId != _deviceId || + !preview.candidates.any( + (candidate) => candidate.candidateId == candidateId, + )) { + return false; + } + final context = _requireLiveDraftContext(requireEdit: true); + final lock = _ownedLiveDraftLocks['callsign']; + final snapshot = _liveDraftSnapshot; + final localFields = _localLiveDraftFields; + if (lock == null || + !lock.expiresAt.isAfter(DateTime.now()) || + snapshot == null || + localFields == null || + snapshot.draft.draftId != preview.draftId) { + return false; + } + try { + final mutationId = _uuidV4(); + late LiveDraftHistoryReuseResultDto result; + for (var attempt = 1;; attempt += 1) { + try { + result = await context.api.selectLiveDraftHistoryCandidate( + sessionId: context.binding.sessionId, + previewId: previewId, + deviceId: context.deviceId, + leaseId: lock.leaseId, + candidateId: candidateId, + idempotencyKey: mutationId, + ); + break; + } on ServerApiException catch (error) { + if (!error.retryable || attempt >= 2) rethrow; + await Future.delayed(const Duration(milliseconds: 120)); + _assertLiveDraftContextCurrent(context); + } + } + _assertLiveDraftContextCurrent(context); + final projected = applyLiveDraftHistoryReuseProjection( + projection: LiveDraftControlProjection( + snapshot: snapshot, + localFields: localFields, + dirtyFields: _dirtyLiveDraftFields, + baseRevisions: _liveDraftBaseRevisions, + changed: false, + ), + canonicalDraft: result.draft, + historyReuse: result.historyReuse, + locks: result.locks, + ); + _liveDraftSnapshot = projected.snapshot; + _localLiveDraftFields = projected.localFields; + _dirtyLiveDraftFields = projected.dirtyFields; + _liveDraftBaseRevisions = projected.baseRevisions; + _liveDraftHistoryPreview = null; + _cancelLiveDraftHistoryPreviewExpiry(); + _recordLiveDraftHistoryReuse(result.historyReuse, result.draft); + _reconcileOwnedLiveDraftLocks(result.locks); + _clearLiveDraftError(); + _safeNotify(); + await _persistLiveDraftState(_guardFor(context)); + return true; + } on ServerApiException catch (error) { + if ({ + 'LIVE_DRAFT_HISTORY_PREVIEW_STALE', + 'LIVE_DRAFT_HISTORY_CANDIDATE_NOT_FOUND', + }.contains(error.code)) { + _liveDraftHistoryPreview = null; + _cancelLiveDraftHistoryPreviewExpiry(); + _safeNotify(); + return false; + } + if (_isUnsupportedLiveDraftHistoryPreviewError(error)) { + _liveDraftHistoryPreviewUnavailable = true; + _liveDraftHistoryPreview = null; + _cancelLiveDraftHistoryPreviewExpiry(); + _safeNotify(); + return false; + } + rethrow; + } + }); + /// Applies a version-checked multi-field update exactly once. This is used /// for externally generated suggestions: a conflict must return to review /// instead of rebasing the same values over a collaborator's newer edit. @@ -2385,6 +3408,7 @@ class CollaborationProvider with ChangeNotifier { _localLiveDraftFields = staged.localFields; _dirtyLiveDraftFields = staged.dirtyFields; _liveDraftBaseRevisions = staged.baseRevisions; + _markLiveDraftLocalFieldsEdited(requested.keys); _safeNotify(); } return _serializeLiveDraft( @@ -2528,6 +3552,10 @@ class CollaborationProvider with ChangeNotifier { await _flushDirtyLiveDraftFields(); _assertLiveDraftContextCurrent(context); } on ServerApiException catch (error) { + await _releaseOwnedLiveDraftFieldsAfterFailure( + _ownedLiveDraftLocks.keys.toList(growable: false), + context, + ); _assertLiveDraftContextCurrent(context); if (!error.retryable) rethrow; final queued = await _queueOfflineRecord( @@ -2557,16 +3585,28 @@ class CollaborationProvider with ChangeNotifier { _safeNotify(); await _persistLiveDraftState(_guardFor(context)); return LiveDraftCommitDisposition.queuedOffline; + } catch (error, stackTrace) { + await _releaseOwnedLiveDraftFieldsAfterFailure( + _ownedLiveDraftLocks.keys.toList(growable: false), + context, + ); + Error.throwWithStackTrace(error, stackTrace); } - final canonical = _liveDraftSnapshot; - final fields = liveDraftFields; - if (canonical == null || fields == null) { + final loadedCanonical = _liveDraftSnapshot; + final loadedFields = liveDraftFields; + if (loadedCanonical == null || loadedFields == null) { throw StateError('LIVE_DRAFT_NOT_LOADED'); } + var canonical = loadedCanonical; + var fields = loadedFields; final required = ['time', 'controller', 'callsign'] .where((field) => fields[field].trim().isEmpty) .toList(growable: false); if (required.isNotEmpty) { + await _releaseOwnedLiveDraftFieldsAfterFailure( + _ownedLiveDraftLocks.keys.toList(growable: false), + context, + ); throw StateError('LIVE_DRAFT_INCOMPLETE:${required.join(',')}'); } try { @@ -2582,12 +3622,49 @@ class CollaborationProvider with ChangeNotifier { throw StateError('LIVE_DRAFT_LOG_CONTEXT_CHANGED'); } } - final committed = await context.api.commitLiveDraft( - sessionId: context.binding.sessionId, - deviceId: context.deviceId, - expectedDraftVersion: canonical.draft.version, - syncId: mutationId, - idempotencyKey: mutationId, + final committed = await executeLiveDraftCommitWithRaceRecovery( + attempt: (_) => context.api.commitLiveDraft( + sessionId: context.binding.sessionId, + deviceId: context.deviceId, + expectedDraftVersion: canonical.draft.version, + syncId: mutationId, + idempotencyKey: mutationId, + ), + recover: (error, attempt) async { + // A delayed WebSocket control can leave an otherwise idle + // device one version behind at the instant Save is pressed. A + // collaborator's final field PATCH can also arrive just before + // its lease-release request on a high-RTT link. Give that short + // release window time to settle, then rebase and retry the same + // idempotent commit instead of making the user press Save again. + if (error.code == 'LIVE_DRAFT_BUSY') { + await Future.delayed( + Duration(milliseconds: 250 * attempt), + ); + } + await _rebaseLiveDraftForAtomicRetry( + liveDraftFieldNames.toSet(), + context, + conflict: error, + ); + await _flushDirtyLiveDraftFields(); + _assertLiveDraftContextCurrent(context); + final refreshedCanonical = _liveDraftSnapshot; + final refreshedFields = liveDraftFields; + if (refreshedCanonical == null || refreshedFields == null) { + throw StateError('LIVE_DRAFT_NOT_LOADED'); + } + canonical = refreshedCanonical; + fields = refreshedFields; + final missing = ['time', 'controller', 'callsign'] + .where((field) => fields[field].trim().isEmpty) + .toList(growable: false); + if (missing.isNotEmpty) { + throw StateError( + 'LIVE_DRAFT_INCOMPLETE:${missing.join(',')}', + ); + } + }, ); _assertLiveDraftContextCurrent(context); // The server has durably accepted the record. Publish it to the @@ -2604,6 +3681,8 @@ class CollaborationProvider with ChangeNotifier { _localLiveDraftFields = committed.nextDraft.fields; _dirtyLiveDraftFields = const {}; _liveDraftBaseRevisions = const {}; + _liveDraftHistoryPreview = null; + _cancelLiveDraftHistoryPreviewExpiry(); _clearOwnedLiveDraftLocks(); _clearLiveDraftError(); // Expose both the new table row and the reset draft immediately. @@ -2619,9 +3698,25 @@ class CollaborationProvider with ChangeNotifier { _assertLiveDraftContextCurrent(context); _safeNotify(); return LiveDraftCommitDisposition.committed; - } on ServerApiException catch (error) { + } on ServerApiException catch (error, stackTrace) { + await _releaseOwnedLiveDraftFieldsAfterFailure( + _ownedLiveDraftLocks.keys.toList(growable: false), + context, + ); _assertLiveDraftContextCurrent(context); if (!error.retryable) { + if ({ + 'LIVE_DRAFT_VERSION_CONFLICT', + 'LIVE_DRAFT_BUSY', + }.contains(error.code)) { + AppLogger.instance.log( + AppLogLevel.warning, + 'Live-draft commit still conflicted after bounded retries', + source: 'CollaborationProvider', + error: error, + stackTrace: stackTrace, + ); + } _setLiveDraftError(error.code, error.message); if ({ 'LIVE_DRAFT_ALREADY_COMMITTED', @@ -2658,6 +3753,12 @@ class CollaborationProvider with ChangeNotifier { _safeNotify(); await _persistLiveDraftState(_guardFor(context)); return LiveDraftCommitDisposition.queuedOffline; + } catch (error, stackTrace) { + await _releaseOwnedLiveDraftFieldsAfterFailure( + _ownedLiveDraftLocks.keys.toList(growable: false), + context, + ); + Error.throwWithStackTrace(error, stackTrace); } }); @@ -2693,6 +3794,8 @@ class CollaborationProvider with ChangeNotifier { _localLiveDraftFields = discarded.nextDraft.fields; _dirtyLiveDraftFields = const {}; _liveDraftBaseRevisions = const {}; + _liveDraftHistoryPreview = null; + _cancelLiveDraftHistoryPreviewExpiry(); _clearOwnedLiveDraftLocks(); _clearLiveDraftError(); _safeNotify(); @@ -2749,6 +3852,8 @@ class CollaborationProvider with ChangeNotifier { _localLiveDraftFields = committed.nextDraft.fields; _dirtyLiveDraftFields = const {}; _liveDraftBaseRevisions = const {}; + _liveDraftHistoryPreview = null; + _cancelLiveDraftHistoryPreviewExpiry(); _clearOwnedLiveDraftLocks(); _safeNotify(); await _refreshLogsAfterLiveDraftCommit( @@ -4172,6 +5277,93 @@ class CollaborationProvider with ChangeNotifier { ); } + Future _hydrateLiveDraftCacheIfEmpty( + LocalCollaborationBinding binding, + bool Function() isCurrent, + ) async { + if (_liveDraftSnapshot != null || _localLiveDraftFields != null) return; + try { + final cachedJson = await RustApi.getCollaborationLiveDraftCache( + serverInstanceId: binding.serverInstanceId, + accountId: binding.accountId, + sessionId: binding.sessionId, + ); + if (!isCurrent() || cachedJson == null) return; + final cached = Map.from( + jsonDecode(cachedJson) as Map, + ); + final remote = cached['remote']; + if (remote != null) { + _liveDraftSnapshot = LiveDraftSnapshotDto.fromJson(remote); + if (remote is Map) { + final remoteValues = Map.from(remote); + final cachedAutomaticTime = + remoteValues['_clientAutomaticTimeSuppression']; + if (cachedAutomaticTime is Map) { + final value = Map.from(cachedAutomaticTime); + final draftId = value['draftId']; + final displayMinute = value['displayMinute']; + final maxTimeRevision = value['maxTimeRevision']; + if (draftId is String && + displayMinute is String && + maxTimeRevision is int && + draftId == _liveDraftSnapshot?.draft.draftId) { + _automaticLiveDraftTime = _AutomaticLiveDraftTimeMarker( + draftId: draftId, + displayMinute: displayMinute, + maxTimeRevision: maxTimeRevision, + ); + } + } + } + } + _localLiveDraftFields = LiveDraftFieldsDto.fromJson( + cached['localFields'], + ); + _dirtyLiveDraftFields = { + for (final value in List.from( + cached['dirtyFields'] as List? ?? const [], + )) + if (liveDraftFieldNames.contains(value.toString())) value.toString(), + }; + final cachedRevisions = cached['fieldRevisions']; + if (cachedRevisions is Map) { + final values = Map.from(cachedRevisions); + _liveDraftBaseRevisions = { + for (final field in _dirtyLiveDraftFields) + if (values[field] is int) field: values[field]! as int, + }; + } else { + _liveDraftBaseRevisions = const {}; + } + final cachedClientSeq = cached['clientSeq'] as int? ?? 0; + _liveDraftClientSeq = max(_liveDraftClientSeq, cachedClientSeq); + _safeNotify(); + } catch (_) { + // A corrupt or old cache is non-authoritative; the server snapshot + // remains the source of truth. + } + } + + Future _loadLiveDraftOfflineRecords( + LocalCollaborationBinding binding, + bool Function() isCurrent, + ) async { + final offlineJson = await RustApi.listCollaborationOfflineRecords( + serverInstanceId: binding.serverInstanceId, + accountId: binding.accountId, + sessionId: binding.sessionId, + ); + if (!isCurrent()) return; + final offlineValues = jsonDecode(offlineJson); + if (offlineValues is! List) { + throw const FormatException('offline record list must be an array'); + } + _offlineRecords = List.unmodifiable( + offlineValues.map(LocalOfflineRecordDto.fromJson), + ); + } + Future _refreshLiveDraftForBinding( LocalCollaborationBinding binding, { required int requestGeneration, @@ -4199,75 +5391,12 @@ class CollaborationProvider with ChangeNotifier { _liveDraftLoading = true; _safeNotify(); try { - if (hydrateCache && - _liveDraftSnapshot == null && - _localLiveDraftFields == null) { - try { - final cachedJson = await RustApi.getCollaborationLiveDraftCache( - serverInstanceId: binding.serverInstanceId, - accountId: binding.accountId, - sessionId: binding.sessionId, - ); - if (!isCurrent()) return; - if (cachedJson != null) { - final cached = Map.from( - jsonDecode(cachedJson) as Map, - ); - final remote = cached['remote']; - if (remote != null) { - _liveDraftSnapshot = LiveDraftSnapshotDto.fromJson(remote); - if (remote is Map) { - final remoteValues = Map.from(remote); - final cachedAutomaticTime = - remoteValues['_clientAutomaticTimeSuppression']; - if (cachedAutomaticTime is Map) { - final value = Map.from(cachedAutomaticTime); - final draftId = value['draftId']; - final displayMinute = value['displayMinute']; - final maxTimeRevision = value['maxTimeRevision']; - if (draftId is String && - displayMinute is String && - maxTimeRevision is int && - draftId == _liveDraftSnapshot?.draft.draftId) { - _automaticLiveDraftTime = _AutomaticLiveDraftTimeMarker( - draftId: draftId, - displayMinute: displayMinute, - maxTimeRevision: maxTimeRevision, - ); - } - } - } - } - _localLiveDraftFields = LiveDraftFieldsDto.fromJson( - cached['localFields'], - ); - _dirtyLiveDraftFields = { - for (final value in List.from( - cached['dirtyFields'] as List? ?? const [], - )) - if (liveDraftFieldNames.contains(value.toString())) - value.toString(), - }; - final cachedRevisions = cached['fieldRevisions']; - if (cachedRevisions is Map) { - final values = Map.from(cachedRevisions); - _liveDraftBaseRevisions = { - for (final field in _dirtyLiveDraftFields) - if (values[field] is int) field: values[field]! as int, - }; - } else { - _liveDraftBaseRevisions = const {}; - } - final cachedClientSeq = cached['clientSeq'] as int? ?? 0; - _liveDraftClientSeq = max(_liveDraftClientSeq, cachedClientSeq); - _safeNotify(); - } - } catch (_) { - // A corrupt or old cache is non-authoritative; the server snapshot - // below remains the source of truth. - } + if (hydrateCache) { + await _hydrateLiveDraftCacheIfEmpty(binding, isCurrent); } + if (!isCurrent()) return; + await _loadLiveDraftOfflineRecords(binding, isCurrent); if (!isCurrent()) return; final previousSnapshot = _liveDraftSnapshot; final incomingSnapshot = @@ -4280,6 +5409,18 @@ class CollaborationProvider with ChangeNotifier { incoming: incomingSnapshot, ); final previousDraftId = previousSnapshot?.draft.draftId; + var displacedLocalDraft = false; + final previousLocalFields = _localLiveDraftFields; + if (previousSnapshot != null && + previousLocalFields != null && + previousDraftId != snapshot.draft.draftId) { + displacedLocalDraft = + await _preserveCurrentDisplacedLiveDraftUntilStable( + context: context, + snapshot: previousSnapshot, + ); + if (!isCurrent()) return; + } final preserveLocal = previousDraftId == snapshot.draft.draftId && _localLiveDraftFields != null && _dirtyLiveDraftFields.isNotEmpty; @@ -4287,6 +5428,24 @@ class CollaborationProvider with ChangeNotifier { previousDraftId != snapshot.draft.draftId) { _clearOwnedLiveDraftLocks(); } + final canAdoptIncomingPreview = previousSnapshot == null || + incomingSnapshot.draft.draftId != previousSnapshot.draft.draftId || + incomingSnapshot.draft.version >= previousSnapshot.draft.version; + if (canAdoptIncomingPreview) { + final preview = incomingSnapshot.historyPreview; + _liveDraftHistoryPreview = preview != null && + preview.draftId == snapshot.draft.draftId && + preview.callsign == snapshot.draft.fields['callsign'] && + preview.expiresAt.isAfter(DateTime.now()) + ? preview + : null; + final adoptedPreview = _liveDraftHistoryPreview; + if (adoptedPreview == null) { + _cancelLiveDraftHistoryPreviewExpiry(); + } else { + _scheduleLiveDraftHistoryPreviewExpiry(adoptedPreview); + } + } _liveDraftSnapshot = snapshot; _reconcileOwnedLiveDraftLocks(snapshot.locks); if (!preserveLocal) { @@ -4308,23 +5467,11 @@ class CollaborationProvider with ChangeNotifier { 0, }; } - final offlineJson = await RustApi.listCollaborationOfflineRecords( - serverInstanceId: binding.serverInstanceId, - accountId: binding.accountId, - sessionId: binding.sessionId, - ); - if (!isCurrent()) return; - final offlineValues = jsonDecode(offlineJson); - if (offlineValues is! List) { - throw const FormatException('offline record list must be an array'); - } - _offlineRecords = List.unmodifiable( - offlineValues.map(LocalOfflineRecordDto.fromJson), - ); _clearLiveDraftError(); await _reconcilePendingOfflineRecords(context, isCurrent); if (!isCurrent()) return; await _persistLiveDraftState(_guardFor(context)); + if (displacedLocalDraft) _setDisplacedLiveDraftWarning(); } on ServerApiException catch (error) { if (isCurrent()) _setLiveDraftError(error.code, error.message); } catch (error) { @@ -4347,18 +5494,271 @@ class CollaborationProvider with ChangeNotifier { if (current != null && current.expiresAt.isAfter(DateTime.now())) { return current; } - final lock = await context.api.acquireLiveDraftLock( + final acquisition = await context.api.acquireLiveDraftLockWithDraft( sessionId: context.binding.sessionId, field: field, deviceId: context.deviceId, ); _assertLiveDraftContextCurrent(context); + final lock = acquisition.lock; + late final ({bool revisionConflict, bool displacedLocalDraft}) adoption; + try { + adoption = await _adoptLiveDraftAtLockAcquisition( + field, + acquisition.draft, + context, + ); + } catch (error, stackTrace) { + await _releaseTemporaryLiveDraftField(field, lock, context); + Error.throwWithStackTrace(error, stackTrace); + } _ownedLiveDraftLocks = {..._ownedLiveDraftLocks, field: lock}; _replaceLiveDraftLock(lock); + if (adoption.revisionConflict) { + await _releaseTemporaryLiveDraftField(field, lock, context); + throw _liveDraftLockAcquisitionConflict(field, acquisition.draft!); + } _ensureLiveDraftRenewalTimer(); + if (adoption.displacedLocalDraft) _setDisplacedLiveDraftWarning(); return lock; } + Future _sendLiveDraftPatchWithControlAck({ + required _LiveDraftContext context, + required int clientSeq, + required List updates, + }) async { + final draft = _liveDraftSnapshot?.draft; + if (draft == null) throw StateError('LIVE_DRAFT_NOT_LOADED'); + final pending = _PendingLiveDraftPatchAck( + sessionId: context.binding.sessionId, + deviceId: context.deviceId, + clientSeq: clientSeq, + draftId: draft.draftId, + expectedValues: Map.unmodifiable({ + for (final update in updates) + update.field: canonicalLiveDraftPatchAckValue( + update.field, + update.value, + ), + }), + expectedRevisions: Map.unmodifiable({ + for (final update in updates) update.field: update.expectedRevision, + }), + ); + _pendingLiveDraftPatchAcks[clientSeq] = pending; + Future resultFromControl() async { + final acknowledgement = await pending.completer.future; + _assertLiveDraftContextCurrent(context); + return LiveDraftPatchResultDto( + draft: acknowledgement.draft, + appliedClientSeq: clientSeq, + replayed: false, + releasedLeases: acknowledgement.releasedLeases, + ); + } + + final controlResult = resultFromControl(); + final httpResult = context.api.updateLiveDraft( + sessionId: context.binding.sessionId, + deviceId: context.deviceId, + clientSeq: clientSeq, + updates: updates, + ); + try { + // A precise member control proves the same durable mutation as the HTTP + // body. Racing them avoids waiting for the full request timeout when a + // proxy drops only the response after the server already committed. + return await Future.any([ + httpResult, + controlResult, + ]); + } on ServerApiException catch (error, stackTrace) { + if (!error.retryable) { + Error.throwWithStackTrace(error, stackTrace); + } + try { + return await controlResult.timeout( + const Duration(milliseconds: 1200), + ); + } on TimeoutException { + Error.throwWithStackTrace(error, stackTrace); + } + } finally { + if (identical(_pendingLiveDraftPatchAcks[clientSeq], pending)) { + _pendingLiveDraftPatchAcks.remove(clientSeq); + } + } + } + + Future<({bool revisionConflict, bool displacedLocalDraft})> + _adoptLiveDraftAtLockAcquisition( + String acquiredField, + LiveDraftDto? incoming, + _LiveDraftContext context, + ) async { + if (incoming == null) { + return (revisionConflict: false, displacedLocalDraft: false); + } + final snapshot = _liveDraftSnapshot; + final local = _localLiveDraftFields; + if (snapshot == null || local == null) { + return (revisionConflict: false, displacedLocalDraft: false); + } + final projection = projectLiveDraftLockAcquisition( + acquiredField: acquiredField, + currentDraft: snapshot.draft, + incomingDraft: incoming, + localFields: local, + dirtyFields: _dirtyLiveDraftFields, + baseRevisions: _liveDraftBaseRevisions, + ); + var displacedLocalDraft = false; + if (projection.generationChanged) { + // A commit or discard may have advanced the generation while the lock + // request crossed the network. The acquired lease belongs to the new + // generation; preserve old local edits before adopting it. + displacedLocalDraft = await _preserveCurrentDisplacedLiveDraftUntilStable( + context: context, + snapshot: snapshot, + ); + _assertLiveDraftContextCurrent(context); + _clearOwnedLiveDraftLocks(); + _automaticLiveDraftTime = null; + } + _liveDraftSnapshot = LiveDraftSnapshotDto( + draft: projection.canonicalDraft, + locks: projection.generationChanged + ? const [] + : snapshot.locks, + currentOrdinal: snapshot.currentOrdinal, + totalRecords: snapshot.totalRecords, + previousRecord: snapshot.previousRecord, + ); + _localLiveDraftFields = projection.localFields; + _dirtyLiveDraftFields = projection.dirtyFields; + _liveDraftBaseRevisions = projection.baseRevisions; + return ( + revisionConflict: projection.conflictedFields.contains(acquiredField), + displacedLocalDraft: displacedLocalDraft, + ); + } + + ServerApiException _liveDraftLockAcquisitionConflict( + String field, + LiveDraftDto canonical, + ) => + ServerApiException( + error: ApiErrorDto( + code: 'LIVE_DRAFT_FIELD_CONFLICT', + message: 'The live draft field changed while acquiring its lock', + requestId: 'client-lock-acquisition', + details: { + 'field': field, + 'currentRevision': canonical.fieldRevisions[field] ?? 0, + 'draftVersion': canonical.version, + 'draftId': canonical.draftId, + 'draft': canonical.toJson(), + }, + ), + statusCode: 409, + retryable: false, + ); + + void _adoptReleasedLiveDraftLeases( + Iterable leases, + ) { + final released = leases.map((lease) => lease.leaseId).toSet(); + if (released.isEmpty) return; + _ownedLiveDraftLocks = Map.unmodifiable({ + for (final entry in _ownedLiveDraftLocks.entries) + if (!released.contains(entry.value.leaseId)) entry.key: entry.value, + }); + final snapshot = _liveDraftSnapshot; + if (snapshot != null) { + _liveDraftSnapshot = LiveDraftSnapshotDto( + draft: snapshot.draft, + locks: List.unmodifiable( + snapshot.locks.where( + (lock) => !released.contains(lock.leaseId), + ), + ), + currentOrdinal: snapshot.currentOrdinal, + totalRecords: snapshot.totalRecords, + previousRecord: snapshot.previousRecord, + ); + } + if (_ownedLiveDraftLocks.isEmpty) { + _liveDraftRenewalTimer?.cancel(); + _liveDraftRenewalTimer = null; + } + } + + Future _releaseLiveDraftLockWithBoundedRetry( + LiveDraftLockDto lock, + _LiveDraftContext context, { + int maxAttempts = 2, + }) async { + for (var attempt = 1;; attempt += 1) { + try { + await context.api + .releaseLiveDraftLock( + sessionId: context.binding.sessionId, + leaseId: lock.leaseId, + deviceId: context.deviceId, + ) + .timeout(const Duration(milliseconds: 1200)); + return; + } on ServerApiException catch (error) { + if ({'LIVE_DRAFT_LOCK_NOT_FOUND', 'LIVE_DRAFT_LOCK_EXPIRED'} + .contains(error.code)) { + return; + } + if (!error.retryable || attempt >= maxAttempts) rethrow; + await Future.delayed(Duration(milliseconds: 100 * attempt)); + } on TimeoutException { + if (attempt >= maxAttempts) rethrow; + await Future.delayed(Duration(milliseconds: 100 * attempt)); + } + } + } + + Future _releaseOwnedLiveDraftFieldsAfterFailure( + Iterable fields, + _LiveDraftContext context, + ) async { + final requested = fields.toSet(); + final locks = [ + for (final entry in _ownedLiveDraftLocks.entries) + if (requested.contains(entry.key) && + entry.value.sessionId == context.binding.sessionId && + entry.value.deviceId == context.deviceId) + entry.value, + ]; + if (locks.isEmpty) return; + final releasedIds = locks.map((lock) => lock.leaseId).toSet(); + _ownedLiveDraftLocks = Map.unmodifiable({ + for (final entry in _ownedLiveDraftLocks.entries) + if (!releasedIds.contains(entry.value.leaseId)) entry.key: entry.value, + }); + for (final leaseId in releasedIds) { + _removeLiveDraftLock(leaseId); + } + if (_ownedLiveDraftLocks.isEmpty) { + _liveDraftRenewalTimer?.cancel(); + _liveDraftRenewalTimer = null; + } + _safeNotify(); + for (final lock in locks) { + try { + await _releaseLiveDraftLockWithBoundedRetry(lock, context); + } catch (_) { + // Mutation failure remains primary. Local renewal has already stopped, + // and a failed bounded cleanup expires naturally on the server. + } + } + } + Future _releaseTemporaryLiveDraftField( String field, LiveDraftLockDto lock, @@ -4373,13 +5773,8 @@ class CollaborationProvider with ChangeNotifier { _liveDraftRenewalTimer = null; } } - if (!_isLiveDraftContextCurrent(context)) return; try { - await context.api.releaseLiveDraftLock( - sessionId: context.binding.sessionId, - leaseId: lock.leaseId, - deviceId: context.deviceId, - ); + await _releaseLiveDraftLockWithBoundedRetry(lock, context); } on ServerApiException catch (error) { if (!_isLiveDraftContextCurrent(context)) return; if (!{'LIVE_DRAFT_LOCK_NOT_FOUND', 'LIVE_DRAFT_LOCK_EXPIRED'} @@ -4403,9 +5798,10 @@ class CollaborationProvider with ChangeNotifier { if (retryRevisionConflicts) { await executeLiveDraftAtomicPatchWithRebaseRetry( attempt: () => _updateLiveDraftFieldsAtomicAttempt(updates, context), - rebase: () => _rebaseLiveDraftForAtomicRetry( + rebase: (conflict) => _rebaseLiveDraftForAtomicRetry( updates.keys.toSet(), context, + conflict: conflict, ), ); } else { @@ -4417,7 +5813,23 @@ class CollaborationProvider with ChangeNotifier { expectedRevisions: expectedRevisions, ); } - } on ServerApiException catch (error) { + } on ServerApiException catch (error, stackTrace) { + await _releaseOwnedLiveDraftFieldsAfterFailure( + updates.keys, + context, + ); + if ({ + 'LIVE_DRAFT_FIELD_CONFLICT', + 'LIVE_DRAFT_VERSION_CONFLICT', + }.contains(error.code)) { + AppLogger.instance.log( + AppLogLevel.warning, + 'Atomic live-draft update still conflicted after bounded retries', + source: 'CollaborationProvider', + error: error, + stackTrace: stackTrace, + ); + } _setLiveDraftError(error.code, error.message); if (error.code == 'LIVE_DRAFT_LOCK_REQUIRED') { final details = error.details; @@ -4440,6 +5852,12 @@ class CollaborationProvider with ChangeNotifier { } await _persistLiveDraftState(_guardFor(context)); rethrow; + } catch (error, stackTrace) { + await _releaseOwnedLiveDraftFieldsAfterFailure( + updates.keys, + context, + ); + Error.throwWithStackTrace(error, stackTrace); } } @@ -4508,10 +5926,29 @@ class CollaborationProvider with ChangeNotifier { expectedRevisions: sentRevisions, ownedLocks: Map.of(_ownedLiveDraftLocks), nextClientSeq: _liveDraftClientSeq + 1, - acquireLock: (field) => _acquireLiveDraftFieldInternal(field, context), - sendPatch: (clientSeq, updates) => context.api.updateLiveDraft( - sessionId: context.binding.sessionId, - deviceId: context.deviceId, + acquireLock: (field) async { + final lock = await _acquireLiveDraftFieldInternal(field, context); + if (_liveDraftSnapshot?.draft.draftId != snapshot.draft.draftId) { + await _releaseTemporaryLiveDraftField(field, lock, context); + throw StateError('LIVE_DRAFT_GENERATION_CHANGED'); + } + if (expectedDraftId != null) { + final canonical = _liveDraftSnapshot?.draft; + if (canonical == null || + canonical.draftId != expectedDraftId || + canonical.fields[field] != expectedValues?[field] || + canonical.fieldRevisions[field] != expectedRevisions?[field]) { + await _releaseTemporaryLiveDraftField(field, lock, context); + throw StateError('LIVE_DRAFT_SUGGESTION_STALE'); + } + } + sentRevisions[field] = _liveDraftBaseRevisions[field] ?? + _liveDraftSnapshot?.draft.fieldRevisions[field] ?? + sentRevisions[field]!; + return lock; + }, + sendPatch: (clientSeq, updates) => _sendLiveDraftPatchWithControlAck( + context: context, clientSeq: clientSeq, updates: updates, ), @@ -4521,6 +5958,7 @@ class CollaborationProvider with ChangeNotifier { assertCurrent: () => _assertLiveDraftContextCurrent(context), ); _assertLiveDraftContextCurrent(context); + _adoptReleasedLiveDraftLeases(execution.result.releasedLeases); acceptedDraft = execution.result.draft; } @@ -4561,9 +5999,25 @@ class CollaborationProvider with ChangeNotifier { Future _rebaseLiveDraftForAtomicRetry( Set targetFields, - _LiveDraftContext context, - ) async { - final incoming = await context.api.getLiveDraft(context.binding.sessionId); + _LiveDraftContext context, { + ServerApiException? conflict, + }) async { + final currentBeforeRead = _liveDraftSnapshot; + final conflictDraft = conflict == null + ? null + : liveDraftCanonicalFromConflict( + conflict, + sessionId: context.binding.sessionId, + ); + final incoming = conflictDraft == null + ? await context.api.getLiveDraft(context.binding.sessionId) + : LiveDraftSnapshotDto( + draft: conflictDraft, + locks: currentBeforeRead?.locks ?? const [], + currentOrdinal: currentBeforeRead?.currentOrdinal ?? 1, + totalRecords: currentBeforeRead?.totalRecords ?? 0, + previousRecord: currentBeforeRead?.previousRecord, + ); _assertLiveDraftContextCurrent(context); final current = _liveDraftSnapshot; final local = _localLiveDraftFields; @@ -4575,22 +6029,40 @@ class CollaborationProvider with ChangeNotifier { incoming: incoming, ); if (current.draft.draftId != rebased.draft.draftId) { + final displacedLocalDraft = + await _preserveCurrentDisplacedLiveDraftUntilStable( + context: context, + snapshot: current, + ); + _assertLiveDraftContextCurrent(context); _clearOwnedLiveDraftLocks(); _liveDraftSnapshot = rebased; _localLiveDraftFields = rebased.draft.fields; _dirtyLiveDraftFields = const {}; _liveDraftBaseRevisions = const {}; + if (displacedLocalDraft) _setDisplacedLiveDraftWarning(); return; } final dirtyFields = Set.of(_dirtyLiveDraftFields); final baseRevisions = Map.of(_liveDraftBaseRevisions); + final conflictedTargetFields = {}; for (final field in List.from(dirtyFields)) { if (local[field] == rebased.draft.fields[field]) { dirtyFields.remove(field); baseRevisions.remove(field); } else if (targetFields.contains(field)) { - baseRevisions[field] = rebased.draft.fieldRevisions[field] ?? 0; + final previousRevision = + baseRevisions[field] ?? current.draft.fieldRevisions[field] ?? 0; + final incomingRevision = rebased.draft.fieldRevisions[field] ?? 0; + if (incomingRevision != previousRevision) { + // A retry may absorb an unrelated version bump, but it must never + // turn a same-field conflict into last-writer-wins. + conflictedTargetFields.add(field); + baseRevisions[field] = previousRevision; + } else { + baseRevisions[field] = incomingRevision; + } } } _liveDraftSnapshot = rebased; @@ -4602,11 +6074,14 @@ class CollaborationProvider with ChangeNotifier { ? local[field] : rebased.draft.fields[field], }); + if (conflict != null && conflictedTargetFields.isNotEmpty) { + throw conflict; + } } Future _flushLiveDraftField( String field, { - bool retryRevisionConflict = true, + int remainingRevisionConflictRetries = 3, }) async { final context = _requireLiveDraftContext(requireEdit: true); final snapshot = _liveDraftSnapshot; @@ -4626,19 +6101,29 @@ class CollaborationProvider with ChangeNotifier { final sentValue = local[field]; final sentRevision = beforeBaseRevisions[field] ?? snapshot.draft.fieldRevisions[field] ?? 0; + final sentRevisions = {field: sentRevision}; try { // Use the same lease lifecycle as a multi-field patch. In particular, // an implicit write such as an auto-generated time must release the // temporary lock it acquired instead of leaving other devices blocked. final execution = await executeLiveDraftAtomicPatch( values: {field: sentValue}, - expectedRevisions: {field: sentRevision}, + expectedRevisions: sentRevisions, ownedLocks: Map.of(_ownedLiveDraftLocks), nextClientSeq: _liveDraftClientSeq + 1, - acquireLock: (field) => _acquireLiveDraftFieldInternal(field, context), - sendPatch: (clientSeq, updates) => context.api.updateLiveDraft( - sessionId: context.binding.sessionId, - deviceId: context.deviceId, + acquireLock: (field) async { + final lock = await _acquireLiveDraftFieldInternal(field, context); + if (_liveDraftSnapshot?.draft.draftId != snapshot.draft.draftId) { + await _releaseTemporaryLiveDraftField(field, lock, context); + throw StateError('LIVE_DRAFT_GENERATION_CHANGED'); + } + sentRevisions[field] = _liveDraftBaseRevisions[field] ?? + _liveDraftSnapshot?.draft.fieldRevisions[field] ?? + sentRevisions[field]!; + return lock; + }, + sendPatch: (clientSeq, updates) => _sendLiveDraftPatchWithControlAck( + context: context, clientSeq: clientSeq, updates: updates, ), @@ -4648,6 +6133,7 @@ class CollaborationProvider with ChangeNotifier { assertCurrent: () => _assertLiveDraftContextCurrent(context), ); _assertLiveDraftContextCurrent(context); + _adoptReleasedLiveDraftLeases(execution.result.releasedLeases); final currentSnapshot = _liveDraftSnapshot; final currentLocal = _localLiveDraftFields; if (currentSnapshot == null || currentLocal == null) return; @@ -4681,26 +6167,48 @@ class CollaborationProvider with ChangeNotifier { _clearLiveDraftError(); await _persistLiveDraftState(_guardFor(context)); _safeNotify(); - } on ServerApiException catch (error) { - if (retryRevisionConflict && + } on ServerApiException catch (error, stackTrace) { + if (remainingRevisionConflictRetries > 0 && { 'LIVE_DRAFT_FIELD_CONFLICT', 'LIVE_DRAFT_VERSION_CONFLICT', }.contains(error.code)) { try { - await _rebaseLiveDraftForAtomicRetry({field}, context); + await _rebaseLiveDraftForAtomicRetry( + {field}, + context, + conflict: error, + ); _assertLiveDraftContextCurrent(context); } on ServerApiException catch (rebaseError) { + await _releaseOwnedLiveDraftFieldsAfterFailure( + {field}, + context, + ); _setLiveDraftError(rebaseError.code, rebaseError.message); await _persistLiveDraftState(_guardFor(context)); rethrow; } await _flushLiveDraftField( field, - retryRevisionConflict: false, + remainingRevisionConflictRetries: + remainingRevisionConflictRetries - 1, ); return; } + await _releaseOwnedLiveDraftFieldsAfterFailure({field}, context); + if ({ + 'LIVE_DRAFT_FIELD_CONFLICT', + 'LIVE_DRAFT_VERSION_CONFLICT', + }.contains(error.code)) { + AppLogger.instance.log( + AppLogLevel.warning, + 'Live-draft field $field still conflicted after bounded retries', + source: 'CollaborationProvider', + error: error, + stackTrace: stackTrace, + ); + } _setLiveDraftError(error.code, error.message); if (error.code == 'LIVE_DRAFT_LOCK_REQUIRED') { final stale = _ownedLiveDraftLocks[field]; @@ -4719,6 +6227,9 @@ class CollaborationProvider with ChangeNotifier { } await _persistLiveDraftState(_guardFor(context)); rethrow; + } catch (error, stackTrace) { + await _releaseOwnedLiveDraftFieldsAfterFailure({field}, context); + Error.throwWithStackTrace(error, stackTrace); } } @@ -4748,6 +6259,118 @@ class CollaborationProvider with ChangeNotifier { } } + Future _preserveDisplacedLiveDraft({ + required _LiveDraftContext context, + required LiveDraftSnapshotDto snapshot, + required LiveDraftFieldsDto localFields, + required Set dirtyFields, + LiveDraftFieldsDto? acceptedFields, + }) async { + if (!hasUnpreservedLiveDraftChanges( + currentDraft: snapshot.draft, + localFields: localFields, + dirtyFields: dirtyFields, + acceptedFields: acceptedFields, + )) { + return false; + } + + final existing = _offlineRecords.any( + (record) => + record.draftId == snapshot.draft.draftId && + record.state != OfflineRecordState.resolved && + record.state != OfflineRecordState.discarded && + liveDraftFieldNames.every( + (field) => + canonicalLiveDraftPatchAckValue( + field, + record.record[field], + ) == + canonicalLiveDraftPatchAckValue( + field, + localFields[field], + ), + ), + ); + if (existing) return true; + + final queued = await _queueOfflineRecord( + context, + mutationId: _uuidV4(), + snapshot: snapshot, + fields: localFields, + ); + _offlineRecords = List.unmodifiable([ + for (final candidate in _offlineRecords) + if (candidate.mutationId != queued.mutationId) candidate, + queued, + ]); + + // This copy belongs to the previous draft generation and must never be + // submitted automatically into the new one. Mark it for explicit review; + // if that secondary write fails, keeping the durable pending copy is still + // safer and the next refresh will classify it from the generation mismatch. + try { + await _updateOfflineRecord( + queued, + state: OfflineRecordState.reviewing, + resolution: null, + lastErrorCode: 'OFFLINE_RECORD_OVERLAPS_SERVER_PROGRESS', + guard: _guardFor(context), + ); + } catch (error, stackTrace) { + AppLogger.instance.log( + AppLogLevel.warning, + 'Saved displaced live draft but could not mark it for review', + source: 'CollaborationProvider', + error: error, + stackTrace: stackTrace, + ); + } + return true; + } + + /// Local keystrokes are projected synchronously and intentionally bypass the + /// network serial queue. Keep sampling until no keystroke occurred while the + /// recovery copy was being written, so adopting a new generation cannot + /// overwrite a value that arrived during that await. + Future _preserveCurrentDisplacedLiveDraftUntilStable({ + required _LiveDraftContext context, + required LiveDraftSnapshotDto snapshot, + LiveDraftFieldsDto? acceptedFields, + }) async { + var displaced = false; + while (true) { + _assertLiveDraftContextCurrent(context); + final localFields = _localLiveDraftFields; + if (localFields == null || + _liveDraftSnapshot?.draft.draftId != snapshot.draft.draftId) { + return displaced; + } + final localEditRevision = _liveDraftLocalEditRevision; + await Future.delayed(const Duration(milliseconds: 40)); + _assertLiveDraftContextCurrent(context); + if (_liveDraftLocalEditRevision != localEditRevision) continue; + displaced = await _preserveDisplacedLiveDraft( + context: context, + snapshot: snapshot, + localFields: localFields, + dirtyFields: Set.of(_dirtyLiveDraftFields), + acceptedFields: acceptedFields, + ) || + displaced; + _assertLiveDraftContextCurrent(context); + if (_liveDraftLocalEditRevision == localEditRevision) return displaced; + } + } + + void _setDisplacedLiveDraftWarning() { + _setLiveDraftError( + 'LIVE_DRAFT_GENERATION_CHANGED_LOCAL_COPY_QUEUED', + '点名草稿已切换到下一位;本机尚未同步完成的输入已保存到待恢复记录,请确认后再处理。', + ); + } + Future _queueOfflineRecord( _LiveDraftContext context, { required String mutationId, @@ -4833,9 +6456,47 @@ class CollaborationProvider with ChangeNotifier { if (!contextIsCurrent()) return; continue; } + var continuation = + _dirtyLiveDraftFields.isEmpty ? null : _localLiveDraftFields; + var continuationEditRevisions = + Map.of(_liveDraftLocalFieldEditRevisions); + var replacedFormWithOfflineRecord = false; + LiveDraftFieldsDto mergedContinuation( + LiveDraftFieldsDto canonicalFields, + ) { + final latestFields = _localLiveDraftFields; + return LiveDraftFieldsDto({ + for (final field in liveDraftFieldNames) + field: (_liveDraftLocalFieldEditRevisions[field] ?? 0) > + (continuationEditRevisions[field] ?? 0) && + latestFields != null + ? latestFields[field] + : continuation?[field] ?? canonicalFields[field], + }); + } + + void adoptContinuation(LiveDraftDto canonicalDraft) { + final merged = mergedContinuation(canonicalDraft.fields); + _localLiveDraftFields = merged; + _dirtyLiveDraftFields = { + for (final field in liveDraftFieldNames) + if (merged[field] != canonicalDraft.fields[field]) field, + }; + _liveDraftBaseRevisions = { + for (final field in _dirtyLiveDraftFields) + field: canonicalDraft.fieldRevisions[field] ?? 0, + }; + } + + void restoreContinuation() { + if (!replacedFormWithOfflineRecord) return; + final canonical = _liveDraftSnapshot; + if (canonical == null) return; + adoptContinuation(canonical.draft); + _safeNotify(); + } + try { - final continuation = - _dirtyLiveDraftFields.isEmpty ? null : _localLiveDraftFields; await _updateOfflineRecord( record, state: OfflineRecordState.submitting, @@ -4843,6 +6504,14 @@ class CollaborationProvider with ChangeNotifier { guard: guard, ); if (!contextIsCurrent()) return; + // Any keystroke made while the state marker was persisted still + // belongs to the continuation. Capture it before temporarily showing + // the queued record itself. + continuation = + _dirtyLiveDraftFields.isEmpty ? null : _localLiveDraftFields; + continuationEditRevisions = + Map.of(_liveDraftLocalFieldEditRevisions); + replacedFormWithOfflineRecord = true; await _copyOfflineRecordIntoCurrentDraft( record, context, @@ -4867,22 +6536,7 @@ class CollaborationProvider with ChangeNotifier { previousRecord: committed.record, ); _clearOwnedLiveDraftLocks(); - if (continuation == null) { - _localLiveDraftFields = committed.nextDraft.fields; - _dirtyLiveDraftFields = const {}; - _liveDraftBaseRevisions = const {}; - } else { - _localLiveDraftFields = continuation; - _dirtyLiveDraftFields = { - for (final field in liveDraftFieldNames) - if (continuation[field] != committed.nextDraft.fields[field]) - field, - }; - _liveDraftBaseRevisions = { - for (final field in _dirtyLiveDraftFields) - field: committed.nextDraft.fieldRevisions[field] ?? 0, - }; - } + adoptContinuation(committed.nextDraft); await _updateOfflineRecord( record, state: OfflineRecordState.resolved, @@ -4898,6 +6552,7 @@ class CollaborationProvider with ChangeNotifier { if (!contextIsCurrent()) return; } on ServerApiException catch (error) { if (!contextIsCurrent()) return; + restoreContinuation(); if (error.retryable) { await _updateOfflineRecord( record, @@ -4916,6 +6571,9 @@ class CollaborationProvider with ChangeNotifier { guard: guard, ); if (!contextIsCurrent()) return; + } catch (error, stackTrace) { + if (contextIsCurrent()) restoreContinuation(); + Error.throwWithStackTrace(error, stackTrace); } } } @@ -5284,6 +6942,11 @@ class CollaborationProvider with ChangeNotifier { onReplicaChanged: reloadExternallyAdvancedProjection, onControlMessage: (message) async { if (!_isSyncCurrent(generation, identity, coordinator)) return; + // Observe our own mutation before entering the serialized projection + // queue. A PATCH whose HTTP response was lost is still acknowledged by + // this control; waiting for _applyLiveDraftControl here would deadlock + // behind the in-flight PATCH operation itself. + _observeLiveDraftPatchControlAck(message); await _applyLiveDraftControl(message); }, onLocalCloseRejected: (mutation, result) async { @@ -5525,6 +7188,69 @@ class CollaborationProvider with ChangeNotifier { : CollaborationState.localOnly; } + bool _isUnsupportedLiveDraftHistoryPreviewError(ServerApiException error) { + return error.statusCode == 404 || + const { + 'NOT_FOUND', + 'ROUTE_NOT_FOUND', + 'METHOD_NOT_ALLOWED', + }.contains(error.code); + } + + void _cancelLiveDraftHistoryPreviewExpiry() { + _liveDraftHistoryPreviewExpiryTimer?.cancel(); + _liveDraftHistoryPreviewExpiryTimer = null; + } + + void _scheduleLiveDraftHistoryPreviewExpiry( + LiveDraftHistoryPreviewDto preview, + ) { + _cancelLiveDraftHistoryPreviewExpiry(); + if (preview.deviceId != _deviceId) return; + final remaining = preview.expiresAt.difference(DateTime.now()); + _liveDraftHistoryPreviewExpiryTimer = Timer( + remaining.isNegative ? Duration.zero : remaining, + () { + _liveDraftHistoryPreviewExpiryTimer = null; + _runLiveDraftInBackground( + _serializeLiveDraft(() async { + final current = _liveDraftHistoryPreview; + if (current?.previewId != preview.previewId || + current?.deviceId != _deviceId) { + return; + } + _liveDraftHistoryPreview = null; + final context = _tryLiveDraftContext(requireEdit: false); + final lock = _ownedLiveDraftLocks['callsign']; + if (context != null && + lock != null && + !_dirtyLiveDraftFields.contains('callsign')) { + final release = + _releaseTemporaryLiveDraftField('callsign', lock, context); + _safeNotify(); + await release; + return; + } + _safeNotify(); + }), + ); + }, + ); + } + + void _recordLiveDraftHistoryReuse( + LiveDraftHistoryReuseDto historyReuse, + LiveDraftDto draft, + ) { + final token = '${draft.sessionId}:${draft.draftId}:${draft.version}:' + '${historyReuse.previewId}:${historyReuse.candidateId}'; + if (_lastLiveDraftHistoryReuseToken == token) return; + _lastLiveDraftHistoryReuseToken = token; + _liveDraftHistoryReuseAffectedFields = + Set.unmodifiable(historyReuse.affectedFields); + _liveDraftHistoryReuseEpoch += 1; + } + void _clearLiveDraftProjection() { _liveDraftGeneration += 1; _clearOwnedLiveDraftLocks(); @@ -5534,6 +7260,11 @@ class CollaborationProvider with ChangeNotifier { _dirtyLiveDraftFields = const {}; _liveDraftBaseRevisions = const {}; _offlineRecords = const []; + _liveDraftHistoryPreview = null; + _cancelLiveDraftHistoryPreviewExpiry(); + _liveDraftHistoryReuseAffectedFields = const {}; + _lastLiveDraftHistoryReuseToken = null; + _liveDraftHistoryPreviewUnavailable = false; _liveDraftClientSeq = 0; _liveDraftLoading = false; _clearLiveDraftError(); @@ -5636,6 +7367,10 @@ class CollaborationProvider with ChangeNotifier { @override void dispose() { _stopSynchronization(); + _liveDraftRenewalTimer?.cancel(); + _liveDraftRenewalTimer = null; + _cancelLiveDraftHistoryPreviewExpiry(); + _pendingLiveDraftPatchAcks.clear(); _catalogTimer?.cancel(); _logs?.setLogMutationGuard(null); _disposed = true; diff --git a/lib/services/server_api.dart b/lib/services/server_api.dart index a8308d9..2e98bb6 100644 --- a/lib/services/server_api.dart +++ b/lib/services/server_api.dart @@ -512,6 +512,18 @@ final class ServerApi { required String sessionId, required String field, required String deviceId, + }) async => + (await acquireLiveDraftLockWithDraft( + sessionId: sessionId, + field: field, + deviceId: deviceId, + )) + .lock; + + Future acquireLiveDraftLockWithDraft({ + required String sessionId, + required String field, + required String deviceId, }) async { if (!liveDraftFieldNames.contains(field)) { throw ArgumentError.value(field, 'field', 'unknown live draft field'); @@ -521,12 +533,11 @@ final class ServerApi { '/sessions/${_segment(sessionId)}/live-draft/locks', body: {'field': field, 'deviceId': deviceId}, ); - final lock = _parseResponse( + final result = _parseResponse( response, - (json) => LiveDraftLockDto.fromJson( - _jsonObject(json, 'liveDraftLockResult')['lock'], - ), + LiveDraftLockAcquisitionDto.fromJson, ); + final lock = result.lock; if (lock.sessionId != sessionId) { throw _clientException( code: 'INVALID_RESPONSE', @@ -534,7 +545,14 @@ final class ServerApi { statusCode: response.statusCode, ); } - return lock; + if (result.draft != null && result.draft!.sessionId != sessionId) { + throw _clientException( + code: 'INVALID_RESPONSE', + message: 'The live draft snapshot belongs to another Session', + statusCode: response.statusCode, + ); + } + return result; } Future renewLiveDraftLock({ @@ -611,10 +629,109 @@ final class ServerApi { 'clientSeq': clientSeq, 'updates': updates.map((update) => update.toJson()).toList(), }, + headers: const { + 'Prefer': 'openlogtool-consume-live-draft-leases', + }, ); return _parseResponse(response, LiveDraftPatchResultDto.fromJson); } + Future publishLiveDraftHistoryPreview({ + required String sessionId, + required String deviceId, + required String leaseId, + required String draftId, + required String callsign, + required List candidates, + }) async { + if (candidates.isEmpty || candidates.length > 10) { + throw ArgumentError.value( + candidates, + 'candidates', + 'must contain between 1 and 10 items', + ); + } + final response = await _authorizedRequest( + 'PUT', + '/sessions/${_segment(sessionId)}/live-draft/history-preview', + body: { + 'deviceId': deviceId, + 'leaseId': leaseId, + 'draftId': draftId, + 'callsign': callsign, + 'candidates': candidates + .map((candidate) => candidate.toJson()) + .toList(growable: false), + }, + ); + final result = + _parseResponse(response, LiveDraftHistoryPreviewResultDto.fromJson); + if (result.draft.sessionId != sessionId || + result.draft.draftId != draftId || + result.historyPreview.draftId != draftId || + result.historyPreview.deviceId != deviceId) { + throw _clientException( + code: 'INVALID_RESPONSE', + message: 'The live draft history preview does not match the request', + statusCode: response.statusCode, + ); + } + return result; + } + + Future clearLiveDraftHistoryPreview({ + required String sessionId, + required String deviceId, + required String previewId, + }) async { + final response = await _authorizedRequest( + 'DELETE', + '/sessions/${_segment(sessionId)}/live-draft/history-preview', + body: {'deviceId': deviceId, 'previewId': previewId}, + ); + _parseResponse(response, (json) { + final result = _jsonObject(json, 'liveDraftHistoryPreviewClearResult'); + if (result['cleared'] != true || result['historyPreview'] != null) { + throw const FormatException( + 'history preview clear response is invalid', + ); + } + }); + } + + Future selectLiveDraftHistoryCandidate({ + required String sessionId, + required String previewId, + required String deviceId, + required String leaseId, + required String candidateId, + required String idempotencyKey, + }) async { + final response = await _authorizedRequest( + 'POST', + '/sessions/${_segment(sessionId)}/live-draft/history-preview/' + '${_segment(previewId)}/select', + body: { + 'deviceId': deviceId, + 'leaseId': leaseId, + 'candidateId': candidateId, + }, + headers: _idempotencyHeaders(idempotencyKey), + ); + final result = + _parseResponse(response, LiveDraftHistoryReuseResultDto.fromJson); + if (result.draft.sessionId != sessionId || + result.historyReuse.previewId != previewId || + result.historyReuse.candidateId != candidateId) { + throw _clientException( + code: 'INVALID_RESPONSE', + message: 'The selected live draft history candidate is invalid', + statusCode: response.statusCode, + ); + } + return result; + } + Future commitLiveDraft({ required String sessionId, required String deviceId, @@ -1043,14 +1160,28 @@ final class ServerApi { return http.Response.fromStream(streamed); })() .timeout(timeout); + final errorCode = response.statusCode >= 400 + ? _apiErrorFromResponse(response)?.code ?? 'HTTP_ERROR' + : null; + final recoverableLiveDraftRace = (method == 'PATCH' && + path.endsWith('/live-draft') && + const { + 'LIVE_DRAFT_FIELD_CONFLICT', + 'LIVE_DRAFT_VERSION_CONFLICT', + 'LIVE_DRAFT_CLIENT_SEQ_GAP', + 'LIVE_DRAFT_CLIENT_SEQ_REUSED', + }.contains(errorCode)) || + (method == 'POST' && + path.endsWith('/live-draft/commit') && + const { + 'LIVE_DRAFT_VERSION_CONFLICT', + 'LIVE_DRAFT_BUSY', + }.contains(errorCode)); final level = response.statusCode >= 500 ? AppLogLevel.error - : response.statusCode >= 400 + : response.statusCode >= 400 && !recoverableLiveDraftRace ? AppLogLevel.warning : AppLogLevel.debug; - final errorCode = response.statusCode >= 400 - ? _apiErrorFromResponse(response)?.code ?? 'HTTP_ERROR' - : null; AppLogger.instance.log( level, '$method $path -> ${response.statusCode} ' diff --git a/lib/widgets/callsign_history_field.dart b/lib/widgets/callsign_history_field.dart index 0514ba9..fe14397 100644 --- a/lib/widgets/callsign_history_field.dart +++ b/lib/widgets/callsign_history_field.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:openlogtool/l10n/l10n.dart'; +import 'package:openlogtool/models/live_draft.dart'; import 'package:openlogtool/src/bridge/rust_api.dart'; import 'package:openlogtool/src/bridge/models/log_entry.dart' as bridge; import 'package:openlogtool/utils/ime_safe_upper_case_formatter.dart'; @@ -18,6 +19,13 @@ typedef CallsignHistoryReuseCallback = Future Function( bridge.LogEntry record, ); +typedef CallsignHistoryCandidatesCallback = Future Function( + String callsign, + List candidates, +); + +typedef CallsignHistoryPreviewClosedCallback = FutureOr Function(); + class CallsignHistoryField extends StatefulWidget { final TextEditingController callsignController; final TextEditingController deviceController; @@ -39,6 +47,9 @@ class CallsignHistoryField extends StatefulWidget { final CallsignHistoryLoader? historyLoader; final bool Function(String field)? canFillField; final CallsignHistoryReuseCallback? onReuseRecord; + final LiveDraftHistoryPreviewDto? remotePreview; + final CallsignHistoryCandidatesCallback? onLocalCandidatesLoaded; + final CallsignHistoryPreviewClosedCallback? onLocalPreviewClosed; const CallsignHistoryField({ super.key, @@ -62,6 +73,9 @@ class CallsignHistoryField extends StatefulWidget { this.historyLoader, this.canFillField, this.onReuseRecord, + this.remotePreview, + this.onLocalCandidatesLoaded, + this.onLocalPreviewClosed, }); @override @@ -81,23 +95,42 @@ class _CallsignHistoryFieldState extends State FocusNode? _keyHandlerNode; FocusOnKeyEventCallback? _previousKeyHandler; Timer? _focusLossTimer; + Timer? _remotePreviewExpiryTimer; + String? _expiredRemotePreviewId; bool _isSelecting = false; int _historyRequestGeneration = 0; int _highlightIndex = -1; final ScrollController _listController = ScrollController(); List _historyItemKeys = const []; + _HistoryOverlayMode? _overlayMode; + bool _localPreviewOpen = false; + String? _localPreviewKey; + late String _observedCallsignText; FocusNode get _effFocus => widget.focusNode ?? _ownFocusNode; - bool get _canUseHistory => widget.enabled && widget.historyEnabled; + bool get _canUseLocalHistory => widget.enabled && widget.historyEnabled; + + LiveDraftHistoryPreviewDto? get _activeRemotePreview { + final preview = widget.remotePreview; + if (preview == null || + preview.previewId == _expiredRemotePreviewId || + preview.candidates.isEmpty || + !preview.expiresAt.isAfter(DateTime.now())) { + return null; + } + return preview; + } @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _historyKeyHandler = _handleKeyEvent; + _observedCallsignText = widget.callsignController.text; _attachKeyHandler(_effFocus); _effFocus.addListener(_onFocusChanged); widget.callsignController.addListener(_onCallsignChanged); + _scheduleRemotePreviewRefresh(); } KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { @@ -105,6 +138,7 @@ class _CallsignHistoryFieldState extends State if (!isKeyPress || !node.hasFocus || _overlayEntry == null || + _overlayMode != _HistoryOverlayMode.local || _history.isEmpty || _isSelecting) { return _previousKeyHandler?.call(node, event) ?? KeyEventResult.ignored; @@ -161,20 +195,27 @@ class _CallsignHistoryFieldState extends State if (oldWidget.callsignController != widget.callsignController) { oldWidget.callsignController.removeListener(_onCallsignChanged); widget.callsignController.addListener(_onCallsignChanged); - _invalidateHistory(); + _observedCallsignText = widget.callsignController.text; + _invalidateLocalHistory(notifyClosed: true); } final wasUsable = oldWidget.enabled && oldWidget.historyEnabled; - if (wasUsable && !_canUseHistory) { - _invalidateHistory(); - } else if (!wasUsable && _canUseHistory) { + if (wasUsable && !_canUseLocalHistory) { + _invalidateLocalHistory(notifyClosed: true); + } else if (!wasUsable && _canUseLocalHistory) { _loadHistory(); } + if (!identical(oldWidget.remotePreview, widget.remotePreview)) { + if (_overlayMode == _HistoryOverlayMode.remote) _hideOverlay(); + _scheduleRemotePreviewRefresh(); + } } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _focusLossTimer?.cancel(); + _remotePreviewExpiryTimer?.cancel(); + _notifyLocalPreviewClosed(); _detachKeyHandler(); _listController.dispose(); _hideOverlay(); @@ -240,76 +281,88 @@ class _CallsignHistoryFieldState extends State } void _onCallsignChanged() { - if (!_canUseHistory) { - _invalidateHistory(); + final textChanged = widget.callsignController.text != _observedCallsignText; + _observedCallsignText = widget.callsignController.text; + if (textChanged) { + _invalidateLocalHistory(notifyClosed: true); + } + if (!_canUseLocalHistory) { + _refreshOverlayForCurrentState(); return; } if (ImeSafeUpperCaseTextFormatter.hasActiveComposition( widget.callsignController.value, )) { - _invalidateHistory(); + _invalidateLocalHistory(notifyClosed: true); return; } _loadHistory(); - if (_overlayEntry != null) _hideOverlay(); + if (_overlayMode == _HistoryOverlayMode.local) _hideOverlay(); + _refreshOverlayForCurrentState(); } void _onFocusChanged() { _focusLossTimer?.cancel(); _focusLossTimer = null; - if (!_canUseHistory) { - _hideOverlay(); + if (!_canUseLocalHistory) { + _refreshOverlayForCurrentState(); return; } if (ImeSafeUpperCaseTextFormatter.hasActiveComposition( widget.callsignController.value, )) { - _invalidateHistory(); + _invalidateLocalHistory(notifyClosed: !_effFocus.hasFocus); return; } final callsign = widget.callsignController.text.trim().toUpperCase(); if (callsign.length < 2) { if (mounted) setState(() => _history = []); - _hideOverlay(); + _invalidateLocalHistory(notifyClosed: true); + _refreshOverlayForCurrentState(); return; } if (_effFocus.hasFocus && _history.isNotEmpty && _history.first.callsign.trim().toUpperCase() == callsign) { - _showOverlay(); + _showLocalOverlay(); } else if (!_effFocus.hasFocus) { _focusLossTimer = Timer(const Duration(milliseconds: 300), () { _focusLossTimer = null; if (!mounted) return; - if (!_effFocus.hasFocus && !_isSelecting) _hideOverlay(); + if (!_effFocus.hasFocus && !_isSelecting) { + _notifyLocalPreviewClosed(); + if (_overlayMode == _HistoryOverlayMode.local) _hideOverlay(); + _refreshOverlayForCurrentState(); + } }); } } Future _loadHistory() async { final requestGeneration = ++_historyRequestGeneration; - if (!_canUseHistory) return; + if (!_canUseLocalHistory) return; if (ImeSafeUpperCaseTextFormatter.hasActiveComposition( widget.callsignController.value, )) { - _invalidateHistory(); + _invalidateLocalHistory(notifyClosed: true); return; } final callsign = widget.callsignController.text.trim().toUpperCase(); if (callsign.length < 2) { if (mounted) setState(() => _history = []); - _hideOverlay(); + _invalidateLocalHistory(notifyClosed: true); + _refreshOverlayForCurrentState(); return; } try { final loader = widget.historyLoader ?? _loadHistoryFromDatabase; final rows = await loader(callsign, _historyLimit); - if (!mounted || !_canUseHistory) return; + if (!mounted || !_canUseLocalHistory) return; if (requestGeneration != _historyRequestGeneration) return; if (ImeSafeUpperCaseTextFormatter.hasActiveComposition( widget.callsignController.value, )) { - _invalidateHistory(); + _invalidateLocalHistory(notifyClosed: true); return; } final current = widget.callsignController.text.trim().toUpperCase(); @@ -321,10 +374,12 @@ class _CallsignHistoryFieldState extends State if (_effFocus.hasFocus && _history.isNotEmpty && _history.first.callsign.trim().toUpperCase() == current && - _overlayEntry == null) { - _showOverlay(); + _activeRemotePreview == null) { + _showLocalOverlay(); } else if (_history.isEmpty) { - _hideOverlay(); + _notifyLocalPreviewClosed(); + if (_overlayMode == _HistoryOverlayMode.local) _hideOverlay(); + _refreshOverlayForCurrentState(); } } catch (_) {} } @@ -335,16 +390,18 @@ class _CallsignHistoryFieldState extends State ) => RustApi.getRecentByCallsign(callsign: callsign, limit: limit); - void _invalidateHistory() { + void _invalidateLocalHistory({bool notifyClosed = false}) { _historyRequestGeneration += 1; _history = const []; - _hideOverlay(); + if (notifyClosed) _notifyLocalPreviewClosed(); + if (_overlayMode == _HistoryOverlayMode.local) _hideOverlay(); } bool _canFill(String field) => widget.canFillField?.call(field) ?? true; Future _fillFromRecord(bridge.LogEntry log) async { _isSelecting = true; + _forgetLocalPreviewWithoutClosing(); _hideOverlay(); try { final onReuseRecord = widget.onReuseRecord; @@ -377,12 +434,139 @@ class _CallsignHistoryFieldState extends State } } - void _showOverlay() { + Future _enqueueLocalPreviewOperation( + FutureOr Function() operation, + ) async { + try { + await operation(); + } catch (_) { + // Preview publication is best-effort. Local history must stay usable + // when a high-latency collaboration request fails. Close callbacks also + // run independently so a slow publish cannot keep a stale preview open. + } + } + + void _openLocalPreview() { + final callback = widget.onLocalCandidatesLoaded; + if (callback == null || _history.isEmpty) return; + final callsign = widget.callsignController.text.trim().toUpperCase(); + final key = '$callsign:${_history.map((row) => row.syncId).join(',')}'; + if (_localPreviewOpen && _localPreviewKey == key) return; + if (_localPreviewOpen) _notifyLocalPreviewClosed(); + _localPreviewOpen = true; + _localPreviewKey = key; + final candidates = List.unmodifiable(_history); + unawaited( + _enqueueLocalPreviewOperation( + () => callback(callsign, candidates), + ), + ); + } + + void _notifyLocalPreviewClosed() { + if (!_localPreviewOpen) return; + _localPreviewOpen = false; + _localPreviewKey = null; + final callback = widget.onLocalPreviewClosed; + if (callback != null) { + unawaited(_enqueueLocalPreviewOperation(callback)); + } + } + + void _forgetLocalPreviewWithoutClosing() { + _localPreviewOpen = false; + _localPreviewKey = null; + } + + void _scheduleRemotePreviewRefresh() { + _remotePreviewExpiryTimer?.cancel(); + _remotePreviewExpiryTimer = null; + _expiredRemotePreviewId = null; + final preview = _activeRemotePreview; + if (preview != null) { + _remotePreviewExpiryTimer = Timer( + preview.expiresAt.difference(DateTime.now()), + () { + _remotePreviewExpiryTimer = null; + if (!mounted) return; + _expiredRemotePreviewId = preview.previewId; + if (_overlayMode == _HistoryOverlayMode.remote) _hideOverlay(); + setState(() {}); + _refreshOverlayForCurrentState(); + }, + ); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _refreshOverlayForCurrentState(); + }); + } + + bool _remotePreviewMatchesCurrentCallsign( + LiveDraftHistoryPreviewDto preview, + ) { + return widget.callsignController.text.trim().toUpperCase() == + preview.callsign; + } + + void _refreshOverlayForCurrentState() { + final remote = _activeRemotePreview; + if (remote != null && _remotePreviewMatchesCurrentCallsign(remote)) { + if (_overlayMode != _HistoryOverlayMode.remote) { + _showRemoteOverlay(remote); + } + return; + } + if (_overlayMode == _HistoryOverlayMode.remote) _hideOverlay(); + final callsign = widget.callsignController.text.trim().toUpperCase(); + if (_canUseLocalHistory && + _effFocus.hasFocus && + _history.isNotEmpty && + _history.first.callsign.trim().toUpperCase() == callsign) { + _showLocalOverlay(); + } + } + + void _showLocalOverlay() { + final items = + _history.map(_HistoryOverlayItem.fromLocal).toList(growable: false); + if (items.isEmpty) return; + _showOverlay( + mode: _HistoryOverlayMode.local, + items: items, + ); + // Publish only after the local overlay is visible. Do not await this call: + // the operator can select a row immediately even on a slow connection. + _openLocalPreview(); + } + + void _showRemoteOverlay(LiveDraftHistoryPreviewDto preview) { + final items = preview.candidates + .map(_HistoryOverlayItem.fromRemote) + .toList(growable: false); + if (items.isEmpty) return; + // A server-owned remote preview supersedes any local preview state. Forget + // it without invoking the owner's close callback, which would otherwise + // dismiss the remote scribe's dropdown. + _forgetLocalPreviewWithoutClosing(); + _showOverlay( + mode: _HistoryOverlayMode.remote, + items: items, + ); + } + + void _showOverlay({ + required _HistoryOverlayMode mode, + required List<_HistoryOverlayItem> items, + }) { _hideOverlay(); - if (!_canUseHistory || _history.isEmpty) return; - _highlightIndex = 0; + if (items.isEmpty || + (mode == _HistoryOverlayMode.local && !_canUseLocalHistory)) { + return; + } + _overlayMode = mode; + _highlightIndex = mode == _HistoryOverlayMode.local ? 0 : -1; final overlay = Overlay.of(context); - final list = List.unmodifiable(_history); + final list = List<_HistoryOverlayItem>.unmodifiable(items); _historyItemKeys = List.generate( list.length, (index) => GlobalKey(debugLabel: 'callsign-history-item-$index'), @@ -518,30 +702,42 @@ class _CallsignHistoryFieldState extends State shrinkWrap: true, itemCount: list.length, itemBuilder: (_, i) { - final log = list[i]; + final item = list[i]; final details = [ - if (log.qth != null && log.qth!.isNotEmpty) - log.qth, - if (log.device != null && - log.device!.isNotEmpty) - log.device, - if (log.antenna != null && - log.antenna!.isNotEmpty) - log.antenna, - ].join(' · '); - final selected = i == _highlightIndex; + item.qth, + item.device, + item.power, + item.antenna, + item.height, + ].where((value) => value.isNotEmpty).join(' · '); + final readOnly = + mode == _HistoryOverlayMode.remote; + final selected = + !readOnly && i == _highlightIndex; return Semantics( key: _historyItemKeys[i], selected: selected, - button: true, + button: !readOnly, + enabled: !readOnly, child: InkWell( - onTap: () => unawaited(_fillFromRecord(log)), - onHover: (hovered) { - if (hovered && _highlightIndex != i) { - _highlightIndex = i; - _overlayEntry?.markNeedsBuild(); - } - }, + key: Key( + readOnly + ? 'callsign-history-remote-row-$i' + : 'callsign-history-local-row-$i', + ), + onTap: readOnly || item.localRecord == null + ? null + : () => unawaited( + _fillFromRecord(item.localRecord!), + ), + onHover: readOnly + ? null + : (hovered) { + if (hovered && _highlightIndex != i) { + _highlightIndex = i; + _overlayEntry?.markNeedsBuild(); + } + }, child: Container( constraints: const BoxConstraints(minHeight: 58), @@ -570,7 +766,9 @@ class _CallsignHistoryFieldState extends State child: Row( children: [ Icon( - Icons.history, + readOnly + ? Icons.visibility_outlined + : Icons.history, size: 14, color: Theme.of(ctx).colorScheme.primary, @@ -582,7 +780,7 @@ class _CallsignHistoryFieldState extends State CrossAxisAlignment.start, children: [ Text( - _formatTime(log.time), + _formatTime(item.sourceTime), style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, @@ -614,7 +812,9 @@ class _CallsignHistoryFieldState extends State ), ), Icon( - Icons.chevron_right, + readOnly + ? Icons.lock_outline + : Icons.chevron_right, size: 16, color: Theme.of(ctx) .colorScheme @@ -644,6 +844,7 @@ class _CallsignHistoryFieldState extends State void _hideOverlay() { final entry = _overlayEntry; _overlayEntry = null; + _overlayMode = null; _historyItemKeys = const []; if (entry == null) return; try { @@ -666,7 +867,7 @@ class _CallsignHistoryFieldState extends State isDense: true, contentPadding: EdgeInsets.symmetric( horizontal: 12, vertical: widget.isCompact ? 10 : 14), - suffixIcon: _canUseHistory + suffixIcon: (_canUseLocalHistory || _activeRemotePreview != null) ? Padding( padding: const EdgeInsets.only(right: 4), child: Icon( @@ -704,3 +905,50 @@ class _CallsignHistoryFieldState extends State String _formatTime(String time) { return formatLogTimeForDisplay(time, includeDate: true); } + +enum _HistoryOverlayMode { local, remote } + +final class _HistoryOverlayItem { + const _HistoryOverlayItem({ + required this.sourceTime, + required this.qth, + required this.device, + required this.power, + required this.antenna, + required this.height, + this.localRecord, + }); + + factory _HistoryOverlayItem.fromLocal(bridge.LogEntry record) { + return _HistoryOverlayItem( + sourceTime: record.time, + qth: record.qth ?? '', + device: record.device ?? '', + power: record.power ?? '', + antenna: record.antenna ?? '', + height: record.height ?? '', + localRecord: record, + ); + } + + factory _HistoryOverlayItem.fromRemote( + LiveDraftHistoryCandidateDto candidate, + ) { + return _HistoryOverlayItem( + sourceTime: candidate.sourceTime, + qth: candidate.qth, + device: candidate.device, + power: candidate.power, + antenna: candidate.antenna, + height: candidate.height, + ); + } + + final String sourceTime; + final String qth; + final String device; + final String power; + final String antenna; + final String height; + final bridge.LogEntry? localRecord; +} diff --git a/lib/widgets/log_form.dart b/lib/widgets/log_form.dart index 57add16..b131d9f 100644 --- a/lib/widgets/log_form.dart +++ b/lib/widgets/log_form.dart @@ -13,6 +13,7 @@ import 'package:openlogtool/providers/dictionary_provider.dart'; import 'package:openlogtool/providers/settings_provider.dart'; import 'package:openlogtool/models/log_entry.dart'; import 'package:openlogtool/models/dictionary_item.dart'; +import 'package:openlogtool/models/live_draft.dart'; import 'package:openlogtool/utils/field_format_suggestions.dart'; import 'package:openlogtool/utils/ime_safe_upper_case_formatter.dart'; import 'package:openlogtool/utils/log_time.dart'; @@ -128,6 +129,9 @@ class _LogFormState extends State with AutomaticKeepAliveClientMixin { late final Map _draftControllerListeners; late final Map _draftFocusListeners; final Set _focusedDraftFields = {}; + final Set _acquiringDraftFields = {}; + bool _disposing = false; + CollaborationProvider? _collaborationProvider; Timer? _lockExpiryTimer; bool _applyingSharedDraft = false; bool _historyReuseInProgress = false; @@ -137,6 +141,11 @@ class _LogFormState extends State with AutomaticKeepAliveClientMixin { String? _lastSharedDraftId; bool _sharedDraftSyncScheduled = false; final Set _deferredSharedDraftFields = {}; + final Set _suppressFocusFlushFields = {}; + Future? _historyPreviewPublish; + String? _historyPreviewPublishCallsign; + int _historyPreviewGeneration = 0; + int _lastHistoryReuseEpoch = 0; late final Map _aiFieldRevisions; int _aiRecordEpoch = 0; static const Set _inlineAiFields = { @@ -201,6 +210,12 @@ class _LogFormState extends State with AutomaticKeepAliveClientMixin { } } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _collaborationProvider = context.read(); + } + bool _handleGlobalShortcut(KeyEvent event) { if (event is! KeyDownEvent) return false; if (event.logicalKey != LogicalKeyboardKey.enter && @@ -237,6 +252,8 @@ class _LogFormState extends State with AutomaticKeepAliveClientMixin { @override void dispose() { + _disposing = true; + _historyPreviewGeneration += 1; HardwareKeyboard.instance.removeHandler(_handleGlobalShortcut); _lockExpiryTimer?.cancel(); _duplicateCallsignDebounce?.cancel(); @@ -249,6 +266,17 @@ class _LogFormState extends State with AutomaticKeepAliveClientMixin { for (final timer in _draftDebounce.values) { timer.cancel(); } + final collaboration = _collaborationProvider; + if (collaboration != null) { + if (collaboration.liveDraftHistoryPreviewOwnedHere) { + unawaited( + collaboration.clearLiveDraftHistoryPreview().catchError((_) {}), + ); + } + for (final field in collaboration.ownedLiveDraftLocks.keys) { + unawaited(collaboration.releaseLiveDraftField(field)); + } + } for (final entry in _draftControllers.entries) { entry.value.removeListener(_draftControllerListeners[entry.key]!); } @@ -274,16 +302,22 @@ class _LogFormState extends State with AutomaticKeepAliveClientMixin { void _syncSharedDraft(CollaborationProvider collaboration) { final snapshot = collaboration.liveDraftSnapshot; final fields = collaboration.liveDraftDisplayFields; + final historyReuseEpoch = collaboration.liveDraftHistoryReuseEpoch; + final forcedHistoryFields = historyReuseEpoch == _lastHistoryReuseEpoch + ? const {} + : collaboration.liveDraftHistoryReuseAffectedFields; if (snapshot == null || fields == null) { _lastSharedDraftId = null; _lastSharedDraftSignature = null; _deferredSharedDraftFields.clear(); + _lastHistoryReuseEpoch = historyReuseEpoch; return; } final signature = '${snapshot.draft.draftId}:${snapshot.draft.version}:' '${fields.toJson()}'; if (_lastSharedDraftSignature == signature && - _deferredSharedDraftFields.isEmpty) { + _deferredSharedDraftFields.isEmpty && + forcedHistoryFields.isEmpty) { return; } final draftChanged = _lastSharedDraftId != snapshot.draft.draftId; @@ -293,12 +327,32 @@ class _LogFormState extends State with AutomaticKeepAliveClientMixin { } _lastSharedDraftId = snapshot.draft.draftId; _lastSharedDraftSignature = signature; + _lastHistoryReuseEpoch = historyReuseEpoch; _applyingSharedDraft = true; try { for (final entry in _draftControllers.entries) { final rawValue = fields[entry.key]; final value = entry.key == 'time' ? _displayLiveDraftTime(rawValue) : rawValue; + if (forcedHistoryFields.contains(entry.key)) { + _draftDebounce.remove(entry.key)?.cancel(); + _deferredSharedDraftFields.remove(entry.key); + final focusNode = _draftFocusNodes[entry.key]; + if (focusNode?.hasFocus ?? false) { + _suppressFocusFlushFields.add(entry.key); + focusNode!.unfocus(); + _focusedDraftFields.remove(entry.key); + } + if (entry.value.text != value || + !entry.value.value.composing.isCollapsed) { + entry.value.value = TextEditingValue( + text: value, + selection: TextSelection.collapsed(offset: value.length), + ); + } + _suppressFocusFlushFields.remove(entry.key); + continue; + } if (_hasActiveUpperCaseComposition(entry.key)) { if (entry.value.text != value) { _deferredSharedDraftFields.add(entry.key); @@ -307,7 +361,9 @@ class _LogFormState extends State with AutomaticKeepAliveClientMixin { } continue; } - if (!draftChanged && _focusedDraftFields.contains(entry.key)) { + if (!draftChanged && + _focusedDraftFields.contains(entry.key) && + collaboration.isLiveDraftFieldDirty(entry.key)) { if (entry.value.text != value) { _deferredSharedDraftFields.add(entry.key); } else { @@ -382,15 +438,40 @@ class _LogFormState extends State with AutomaticKeepAliveClientMixin { !collaboration.canEditLiveDraft) { return; } + collaboration.stageLiveDraftField( + field, + _draftControllers[field]!.text, + ); + if (collaboration.isLiveDraftFieldDirty(field) && + !collaboration.ownedLiveDraftLocks.containsKey(field)) { + unawaited(_acquireDraftField(field, collaboration)); + } _draftDebounce.remove(field)?.cancel(); _draftDebounce[field] = Timer(const Duration(milliseconds: 250), () { _draftDebounce.remove(field); if (!mounted) return; - unawaited( - collaboration - .updateLiveDraftField(field, _draftControllers[field]!.text) - .catchError((Object _) {}), - ); + final value = _draftControllers[field]!.text; + unawaited(() async { + try { + final flushedLeaseId = + collaboration.ownedLiveDraftLocks[field]?.leaseId; + await collaboration.flushLiveDraftField(field); + if (!mounted || + _draftControllers[field]!.text != value || + collaboration.isLiveDraftFieldDirty(field)) { + return; + } + // Keep the lease only while an edit is crossing the network. An + // idle cursor must not block another scribe from saving the record; + // a later keystroke acquires a fresh lease. + await collaboration.releaseLiveDraftFieldIfClean( + field, + expectedLeaseId: flushedLeaseId, + ); + } catch (_) { + // The provider retains the dirty value and exposes the final error. + } + }()); }); } @@ -571,11 +652,9 @@ class _LogFormState extends State with AutomaticKeepAliveClientMixin { focused ? _focusedDraftFields.add(field) : _focusedDraftFields.remove(field); - if (focused && collaboration.canEditLiveDraft) { - unawaited(_acquireDraftField(field, collaboration)); - return; + if (!focused && !_suppressFocusFlushFields.remove(field)) { + unawaited(_flushAndReleaseDraftField(field, collaboration)); } - if (!focused) unawaited(_flushAndReleaseDraftField(field, collaboration)); } void _scheduleDuplicateCallsignCheck() { @@ -733,14 +812,26 @@ class _LogFormState extends State with AutomaticKeepAliveClientMixin { String field, CollaborationProvider collaboration, ) async { + if (!_acquiringDraftFields.add(field)) return; try { - await collaboration.acquireLiveDraftField(field); + final acquired = await collaboration.acquireLiveDraftField(field); + if (_disposing || + !mounted || + !collaboration.isLiveDraftFieldDirty(field)) { + await collaboration.releaseLiveDraftFieldIfClean( + field, + expectedLeaseId: acquired.leaseId, + ); + } } catch (_) { + if (_disposing || !mounted) return; // Refreshing exposes the holder and disables the field after a lock race. try { await collaboration.refreshLiveDraft(); } catch (_) {} if (mounted) setState(() {}); + } finally { + _acquiringDraftFields.remove(field); } } @@ -760,7 +851,7 @@ class _LogFormState extends State with AutomaticKeepAliveClientMixin { // The provider exposes the protocol error and retains the local value. } } - await collaboration.releaseLiveDraftField(field); + await collaboration.releaseLiveDraftFieldIfClean(field); if (mounted && _deferredSharedDraftFields.contains(field)) { _syncSharedDraft(collaboration); } @@ -798,6 +889,99 @@ class _LogFormState extends State with AutomaticKeepAliveClientMixin { } } + Future _publishHistoryPreview( + String callsign, + List records, + ) async { + if (!mounted || records.isEmpty) return; + final collaboration = context.read(); + final normalizedCallsign = callsign.trim().toUpperCase(); + if (normalizedCallsign.isEmpty || + collaboration.liveDraftSnapshot == null || + !collaboration.canEditLiveDraft || + !_callsignFocusNode.hasFocus || + _callsignController.text.trim().toUpperCase() != normalizedCallsign) { + return; + } + final candidates = []; + final seenIds = {}; + for (final record in records) { + final sourceTime = DateTime.tryParse(record.time)?.toUtc(); + if (sourceTime == null || + record.syncId.isEmpty || + !seenIds.add(record.syncId)) { + continue; + } + final candidate = LiveDraftHistoryCandidateDto( + candidateId: record.syncId, + sourceTime: sourceTime.toIso8601String(), + qth: record.qth?.trim() ?? '', + device: record.device?.trim() ?? '', + power: record.power?.trim() ?? '', + antenna: record.antenna?.trim() ?? '', + height: record.height?.trim() ?? '', + ); + if (candidate.reusableValues.isEmpty) continue; + candidates.add(candidate); + if (candidates.length == 10) break; + } + if (candidates.isEmpty) return; + + final generation = ++_historyPreviewGeneration; + _historyPreviewPublishCallsign = normalizedCallsign; + late final Future operation; + operation = () async { + try { + // History lookup may finish before the normal 250 ms PATCH debounce. + // Commit the callsign first, then publish under a freshly held lease. + _draftDebounce.remove('callsign')?.cancel(); + await collaboration.flushLiveDraftField('callsign'); + if (!mounted || + generation != _historyPreviewGeneration || + !_callsignFocusNode.hasFocus || + _callsignController.text.trim().toUpperCase() != + normalizedCallsign) { + return null; + } + final preview = await collaboration.publishLiveDraftHistoryPreview( + callsign: normalizedCallsign, + candidates: candidates, + ); + if (!mounted || generation != _historyPreviewGeneration) { + if (preview != null) { + await collaboration.clearLiveDraftHistoryPreview( + expectedPreviewId: preview.previewId, + ); + } + return null; + } + return preview; + } catch (_) { + // Cross-device preview is an enhancement. Local history reuse remains + // available when the server is old, offline, or temporarily slow. + return null; + } + }(); + _historyPreviewPublish = operation; + await operation; + if (identical(_historyPreviewPublish, operation)) { + _historyPreviewPublish = null; + _historyPreviewPublishCallsign = null; + } + } + + Future _dismissHistoryPreview() async { + _historyPreviewGeneration += 1; + final collaboration = _collaborationProvider; + if (collaboration != null) { + try { + await collaboration.clearLiveDraftHistoryPreview(); + } catch (_) { + // The preview expires server-side and must never block local input. + } + } + } + Future _reuseHistoryRecord(bridge.LogEntry record) async { final values = { if (record.device?.isNotEmpty ?? false) 'device': record.device!, @@ -810,25 +994,53 @@ class _LogFormState extends State with AutomaticKeepAliveClientMixin { final collaboration = context.read(); if (collaboration.liveDraftSnapshot != null) { - for (final field in values.keys) { - final holder = collaboration.lockForField(field); - if (holder != null && - holder.expiresAt.isAfter(DateTime.now()) && - collaboration.fieldLockedByAnotherUser(field)) { - ScaffoldMessenger.of(context).showLoggedSnackBar( - SnackBar( - content: Text(context.l10n.fieldLockedBy(holder.username))), + setState(() => _historyReuseInProgress = true); + try { + final pendingPreview = _historyPreviewPublishCallsign == + _callsignController.text.trim().toUpperCase() + ? _historyPreviewPublish + : null; + if (pendingPreview != null) await pendingPreview; + if (!mounted) return; + final preview = collaboration.liveDraftHistoryPreview; + if (preview != null && + collaboration.liveDraftHistoryPreviewOwnedHere && + preview.candidates.any( + (candidate) => candidate.candidateId == record.syncId, + )) { + final selected = await collaboration.selectLiveDraftHistoryCandidate( + previewId: preview.previewId, + candidateId: record.syncId, ); - return; + if (selected) { + _callsignFocusNode.unfocus(); + await collaboration.releaseLiveDraftFieldIfClean('callsign'); + return; + } } - } + if (!mounted) return; - for (final field in values.keys) { - _draftDebounce.remove(field)?.cancel(); - } - _unfocusDraftFields(); - setState(() => _historyReuseInProgress = true); - try { + // Compatibility path for servers without shared history previews. + // Unlike the canonical select route, a legacy atomic PATCH may not + // override fields actively leased by another member. + for (final field in values.keys) { + final holder = collaboration.lockForField(field); + if (holder != null && + holder.expiresAt.isAfter(DateTime.now()) && + collaboration.fieldLockedByAnotherUser(field)) { + ScaffoldMessenger.of(context).showLoggedSnackBar( + SnackBar( + content: Text(context.l10n.fieldLockedBy(holder.username)), + ), + ); + return; + } + } + + for (final field in values.keys) { + _draftDebounce.remove(field)?.cancel(); + } + _unfocusDraftFields(); await collaboration.updateLiveDraftFieldsOptimistically(values); } catch (error) { if (mounted) { @@ -1446,6 +1658,18 @@ class _LogFormState extends State with AutomaticKeepAliveClientMixin { enabled: fieldEnabled('callsign'), historyEnabled: settingsProvider.callSignQthLinkEnabled, onReuseRecord: _reuseHistoryRecord, + remotePreview: sharedDraft && + !collaboration.liveDraftHistoryPreviewOwnedHere + ? collaboration.liveDraftHistoryPreview + : null, + onLocalCandidatesLoaded: + sharedDraft && collaboration.canEditLiveDraft + ? _publishHistoryPreview + : null, + onLocalPreviewClosed: + sharedDraft && collaboration.canEditLiveDraft + ? _dismissHistoryPreview + : null, validator: (value) { if (value == null || value.trim().isEmpty) { return context.l10n.callsignRequired; diff --git a/pubspec.yaml b/pubspec.yaml index 1d5fbe3..de27632 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: openlogtool description: OpenLogTool publish_to: 'none' -version: 2.9.3-R +version: 2.9.4-R license: AGPL-3.0 homepage: https://github.com/Mazha0309/OpenLogTool repository: https://github.com/Mazha0309/OpenLogTool.git diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 08fe9a1..27d4e98 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -776,7 +776,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "openlogtool_core" -version = "2.9.3-R" +version = "2.9.4-R" dependencies = [ "anyhow", "chrono", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 2c4a912..a5491f4 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openlogtool_core" -version = "2.9.3-R" +version = "2.9.4-R" edition = "2021" license = "AGPL-3.0" diff --git a/test/models/live_draft_test.dart b/test/models/live_draft_test.dart index 6704fcc..9e7a72c 100644 --- a/test/models/live_draft_test.dart +++ b/test/models/live_draft_test.dart @@ -51,6 +51,126 @@ void main() { throwsA(isA()), ); }); + + test('lock acquisition accepts both legacy and canonical-draft responses', + () { + final lockJson = { + 'leaseId': 'lease-1', + 'sessionId': 'session-1', + 'field': 'callsign', + 'userId': 'user-1', + 'username': 'scribe', + 'deviceId': 'device-1', + 'expiresAt': '2026-07-13T08:00:30.000Z', + }; + final legacy = LiveDraftLockAcquisitionDto.fromJson({'lock': lockJson}); + final enhanced = LiveDraftLockAcquisitionDto.fromJson({ + 'lock': lockJson, + 'draft': _draftJson(version: 3), + }); + + expect(legacy.lock.leaseId, 'lease-1'); + expect(legacy.draft, isNull); + expect(enhanced.draft?.version, 3); + }); + + test('patch response accepts optional consumed lease objects', () { + final legacy = LiveDraftPatchResultDto.fromJson({ + 'draft': _draftJson(version: 2), + 'appliedClientSeq': 1, + 'replayed': false, + }); + final consumed = LiveDraftPatchResultDto.fromJson({ + 'draft': _draftJson(version: 2), + 'appliedClientSeq': 1, + 'replayed': false, + 'releasedLeases': [ + {'field': 'callsign', 'leaseId': 'lease-1'}, + ], + }); + + expect(legacy.releasedLeases, isEmpty); + expect(consumed.releasedLeases.single.field, 'callsign'); + expect(consumed.releasedLeases.single.leaseId, 'lease-1'); + expect( + () => LiveDraftPatchResultDto.fromJson({ + 'draft': _draftJson(version: 2), + 'appliedClientSeq': 1, + 'replayed': false, + 'releasedLeases': [ + {'field': 'not-a-field', 'leaseId': 'lease-1'}, + ], + }), + throwsFormatException, + ); + }); + + test('history preview is ephemeral and reuse only carries station fields', + () { + final previewJson = { + 'previewId': 'preview-1', + 'draftId': 'draft-1', + 'deviceId': 'device-1', + 'callsign': 'bg0test', + 'actor': {'userId': 'user-1', 'username': 'scribe'}, + 'expiresAt': '2026-07-13T08:00:20.000Z', + 'candidates': [ + { + 'candidateId': 'history-1', + 'sourceTime': '2026-07-12T08:00:00.000Z', + 'qth': '杭州', + 'device': null, + 'power': '5W', + 'antenna': 'DP', + 'height': null, + }, + ], + }; + final snapshot = LiveDraftSnapshotDto.fromJson({ + 'draft': _draftJson(), + 'locks': const [], + 'currentOrdinal': 1, + 'totalRecords': 0, + 'previousRecord': null, + 'historyPreview': previewJson, + }); + + expect(snapshot.historyPreview?.callsign, 'BG0TEST'); + expect(snapshot.historyPreview?.candidates.single.reusableValues, { + 'qth': '杭州', + 'power': '5W', + 'antenna': 'DP', + }); + // A preview expires after seconds and must never be persisted in the Rust + // live-draft recovery cache. + expect(snapshot.toJson(), isNot(contains('historyPreview'))); + + final result = LiveDraftHistoryReuseResultDto.fromJson({ + 'draft': _draftJson(version: 2), + 'updatedFields': ['qth', 'power'], + 'releasedLeases': [ + {'field': 'qth', 'leaseId': 'lease-qth'}, + ], + 'locks': const [], + 'historyPreview': null, + 'historyReuse': { + 'previewId': 'preview-1', + 'candidateId': 'history-1', + 'affectedFields': ['qth', 'power'], + }, + }); + expect(result.updatedFields, {'qth', 'power'}); + expect(result.historyReuse.affectedFields, {'qth', 'power'}); + expect(result.releasedLeases.single.leaseId, 'lease-qth'); + expect( + () => LiveDraftHistoryReuseDto.fromJson({ + 'previewId': 'preview-1', + 'candidateId': 'history-1', + 'affectedFields': ['time'], + }), + throwsFormatException, + ); + }); } Map _draftJson({ diff --git a/test/providers/collaboration_provider_test.dart b/test/providers/collaboration_provider_test.dart index add9807..67a2a90 100644 --- a/test/providers/collaboration_provider_test.dart +++ b/test/providers/collaboration_provider_test.dart @@ -257,6 +257,7 @@ void main() { () async { final acquireStarted = Completer(); final acquireResult = Completer(); + final released = []; var current = true; var patchCount = 0; @@ -273,7 +274,7 @@ void main() { patchCount += 1; return _patchResult(1); }, - releaseLock: (_, __) async {}, + releaseLock: (field, _) async => released.add(field), onClientSeqChanged: (_) {}, assertCurrent: () { if (!current) throw StateError('LIVE_DRAFT_CONTEXT_CHANGED'); @@ -295,6 +296,75 @@ void main() { ), ); expect(patchCount, 0); + expect(released, ['callsign']); + }); + + test('server-consumed leases skip redundant DELETE round trips', () async { + final released = []; + + final execution = await executeLiveDraftAtomicPatch( + values: const {'device': 'IC-705', 'antenna': 'Yagi'}, + expectedRevisions: const {'device': 0, 'antenna': 0}, + ownedLocks: const {}, + nextClientSeq: 1, + acquireLock: (field) async => _lock(field, 'new-$field'), + sendPatch: (clientSeq, _) async => _patchResult( + clientSeq, + releasedLeases: const [ + LiveDraftReleasedLeaseDto( + field: 'device', + leaseId: 'new-device', + ), + LiveDraftReleasedLeaseDto( + field: 'antenna', + leaseId: 'new-antenna', + ), + ], + ), + releaseLock: (field, _) async => released.add(field), + onClientSeqChanged: (_) {}, + ); + + expect(execution.result.releasedLeases, hasLength(2)); + expect(released, isEmpty); + }); + + test('a later dirty value reacquires after the prior lease was consumed', + () async { + final acquired = []; + final released = []; + var seq = 0; + + Future patch(String value) async { + await executeLiveDraftAtomicPatch( + values: {'qth': value}, + expectedRevisions: {'qth': seq}, + ownedLocks: const {}, + nextClientSeq: seq + 1, + acquireLock: (field) async { + acquired.add(field); + return _lock(field, 'lease-${seq + 1}'); + }, + sendPatch: (clientSeq, _) async => _patchResult( + clientSeq, + releasedLeases: [ + LiveDraftReleasedLeaseDto( + field: 'qth', + leaseId: 'lease-$clientSeq', + ), + ], + ), + releaseLock: (field, _) async => released.add(field), + onClientSeqChanged: (value) => seq = value, + ); + } + + await patch('A'); + await patch('B'); + + expect(acquired, ['qth', 'qth']); + expect(seq, 2); + expect(released, isEmpty); }); test('a PATCH failure releases every lock acquired by the batch', () async { @@ -648,7 +718,102 @@ void main() { ); }); - test('field and version conflicts rebase and retry exactly once', () async { + test('lock acquisition rebases only the acquired dirty field', () { + final current = _draft( + version: 4, + values: const { + 'callsign': 'BA4AAA', + 'qth': 'old-remote-qth', + 'remarks': 'old-remote-remarks', + }, + revisions: const {'callsign': 2, 'qth': 7, 'remarks': 3}, + ); + final incoming = _draft( + version: 5, + values: const { + 'callsign': 'BA4BBB', + 'qth': 'new-remote-qth', + 'remarks': 'new-remote-remarks', + }, + revisions: const {'callsign': 3, 'qth': 8, 'remarks': 4}, + ); + + final projection = projectLiveDraftLockAcquisition( + acquiredField: 'qth', + currentDraft: current, + incomingDraft: incoming, + localFields: current.fields + .withField('qth', 'local-qth') + .withField('remarks', 'local-remarks'), + dirtyFields: const {'qth', 'remarks'}, + baseRevisions: const {'qth': 7, 'remarks': 3}, + ); + + expect(projection.canonicalDraft, same(incoming)); + expect(projection.localFields['callsign'], 'BA4BBB'); + expect(projection.localFields['qth'], 'local-qth'); + expect(projection.localFields['remarks'], 'local-remarks'); + expect(projection.dirtyFields, {'qth', 'remarks'}); + expect(projection.baseRevisions, {'qth': 7, 'remarks': 3}); + expect(projection.conflictedFields, {'qth'}); + expect(projection.generationChanged, isFalse); + }); + + test('lock acquisition drops old local state after a generation change', + () { + final current = _draft(version: 4); + final incoming = _draft( + draftId: 'draft-2', + version: 1, + values: const {'callsign': 'BA4NEW'}, + ); + + final projection = projectLiveDraftLockAcquisition( + acquiredField: 'callsign', + currentDraft: current, + incomingDraft: incoming, + localFields: current.fields.withField('callsign', 'BA4LOCAL'), + dirtyFields: const {'callsign'}, + baseRevisions: const {'callsign': 2}, + ); + + expect(projection.canonicalDraft, same(incoming)); + expect(projection.localFields['callsign'], 'BA4NEW'); + expect(projection.dirtyFields, isEmpty); + expect(projection.baseRevisions, isEmpty); + expect(projection.conflictedFields, isEmpty); + expect(projection.generationChanged, isTrue); + }); + + test('idle release is rejected after a new edit or lease replacement', () { + final lock = _lock('qth', 'lease-old'); + expect( + canReleaseIdleLiveDraftLease( + fieldDirty: false, + currentLock: lock, + expectedLeaseId: 'lease-old', + ), + isTrue, + ); + expect( + canReleaseIdleLiveDraftLease( + fieldDirty: true, + currentLock: lock, + expectedLeaseId: 'lease-old', + ), + isFalse, + ); + expect( + canReleaseIdleLiveDraftLease( + fieldDirty: false, + currentLock: _lock('qth', 'lease-new'), + expectedLeaseId: 'lease-old', + ), + isFalse, + ); + }); + + test('field and version conflicts recover after one rebase', () async { for (final code in const [ 'LIVE_DRAFT_FIELD_CONFLICT', 'LIVE_DRAFT_VERSION_CONFLICT', @@ -661,7 +826,7 @@ void main() { if (attempts == 1) throw _serverError(code); return 7; }, - rebase: () async => rebases += 1, + rebase: (_) async => rebases += 1, ); expect(result, 7, reason: code); @@ -670,8 +835,7 @@ void main() { } }); - test('a second atomic conflict is returned without a third attempt', - () async { + test('atomic conflicts retry to the bounded attempt limit', () async { var attempts = 0; var rebases = 0; @@ -681,7 +845,7 @@ void main() { attempts += 1; throw _serverError('LIVE_DRAFT_FIELD_CONFLICT'); }, - rebase: () async => rebases += 1, + rebase: (_) async => rebases += 1, ), throwsA( isA().having( @@ -692,8 +856,120 @@ void main() { ), ); - expect(attempts, 2); - expect(rebases, 1); + expect(attempts, 4); + expect(rebases, 3); + }); + + test('uses the canonical draft embedded in a field conflict', () { + final canonical = _draft( + version: 9, + values: const {'callsign': 'BG5CRL'}, + revisions: const {'callsign': 8}, + ); + final extracted = liveDraftCanonicalFromConflict( + _serverError( + 'LIVE_DRAFT_FIELD_CONFLICT', + details: {'draft': canonical.toJson()}, + ), + sessionId: 'session-1', + ); + + expect(extracted?.version, 9); + expect(extracted?.fieldRevisions['callsign'], 8); + expect( + liveDraftCanonicalFromConflict( + _serverError( + 'LIVE_DRAFT_FIELD_CONFLICT', + details: { + 'draft': _draft( + version: 9, + sessionId: 'another-session', + ).toJson(), + }, + ), + sessionId: 'session-1', + ), + isNull, + ); + }); + }); + + group('live-draft commit race recovery', () { + test('recovers version and busy races before the fourth attempt', () async { + final attempts = []; + final recoveries = <({String code, int attempt})>[]; + + final result = await executeLiveDraftCommitWithRaceRecovery( + attempt: (attempt) async { + attempts.add(attempt); + if (attempt == 1) { + throw _serverError('LIVE_DRAFT_VERSION_CONFLICT'); + } + if (attempt == 2) throw _serverError('LIVE_DRAFT_BUSY'); + return 17; + }, + recover: (error, attempt) async { + recoveries.add((code: error.code, attempt: attempt)); + }, + ); + + expect(result, 17); + expect(attempts, [1, 2, 3]); + expect(recoveries, [ + (code: 'LIVE_DRAFT_VERSION_CONFLICT', attempt: 1), + (code: 'LIVE_DRAFT_BUSY', attempt: 2), + ]); + }); + + test('returns the fourth recoverable conflict without another recovery', + () async { + var attempts = 0; + var recoveries = 0; + + await expectLater( + executeLiveDraftCommitWithRaceRecovery( + attempt: (_) async { + attempts += 1; + throw _serverError('LIVE_DRAFT_BUSY'); + }, + recover: (_, __) async => recoveries += 1, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'LIVE_DRAFT_BUSY', + ), + ), + ); + + expect(attempts, 4); + expect(recoveries, 3); + }); + + test('does not retry a non-recoverable commit rejection', () async { + var attempts = 0; + var recoveries = 0; + + await expectLater( + executeLiveDraftCommitWithRaceRecovery( + attempt: (_) async { + attempts += 1; + throw _serverError('LIVE_DRAFT_INCOMPLETE'); + }, + recover: (_, __) async => recoveries += 1, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'LIVE_DRAFT_INCOMPLETE', + ), + ), + ); + + expect(attempts, 1); + expect(recoveries, 0); }); }); @@ -907,6 +1183,171 @@ void main() { }); group('live-draft realtime controls', () { + test('self acknowledgement values mirror server normalization', () { + expect( + canonicalLiveDraftPatchAckValue('callsign', ' bg5crl '), + 'BG5CRL', + ); + expect( + canonicalLiveDraftPatchAckValue('controller', ' bg5ctrl '), + 'BG5CTRL', + ); + expect( + canonicalLiveDraftPatchAckValue('device', ' IC-705 '), + 'IC-705', + ); + expect(canonicalLiveDraftPatchAckValue('qth', ' '), ''); + expect(canonicalLiveDraftPatchAckValue('remarks', null), ''); + }); + + test('generation recovery keeps only input absent from accepted state', () { + final current = _draft( + version: 4, + values: const {'callsign': '', 'qth': '杭州'}, + ); + + expect( + hasUnpreservedLiveDraftChanges( + currentDraft: current, + localFields: current.fields.withField('qth', '杭州'), + dirtyFields: const {'qth'}, + ), + isFalse, + ); + expect( + hasUnpreservedLiveDraftChanges( + currentDraft: current, + localFields: current.fields + .withField('callsign', ' bg5crl ') + .withField('qth', '萧山'), + dirtyFields: const {'callsign', 'qth'}, + acceptedFields: LiveDraftFieldsDto.empty() + .withField('callsign', 'BG5CRL') + .withField('qth', '杭州'), + ), + isTrue, + reason: 'QTH is still absent even though the callsign was accepted', + ); + expect( + hasUnpreservedLiveDraftChanges( + currentDraft: current, + localFields: current.fields.withField('callsign', ' bg5crl '), + dirtyFields: const {'callsign'}, + acceptedFields: + LiveDraftFieldsDto.empty().withField('callsign', 'BG5CRL'), + ), + isFalse, + ); + }); + + test('own updated control precisely acknowledges a lost HTTP response', () { + final incoming = _draft( + version: 5, + values: const {'qth': '杭州'}, + revisions: const {'qth': 8}, + ); + final acknowledgement = matchLiveDraftPatchControlAck( + message: { + 'type': 'liveDraft.updated', + 'sessionId': 'session-1', + 'deviceId': 'device-1', + 'clientSeq': 12, + 'updatedFields': ['qth'], + 'releasedLeases': [ + {'field': 'qth', 'leaseId': 'lease-qth'}, + ], + 'draft': incoming.toJson(), + }, + sessionId: 'session-1', + deviceId: 'device-1', + clientSeq: 12, + draftId: 'draft-1', + expectedValues: const {'qth': '杭州'}, + expectedRevisions: const {'qth': 7}, + ); + + expect(acknowledgement?.draft.toJson(), incoming.toJson()); + expect(acknowledgement?.releasedLeases.single.field, 'qth'); + expect( + acknowledgement?.releasedLeases.single.leaseId, + 'lease-qth', + ); + }); + + test('self acknowledgement rejects the wrong fields, value, or sequence', + () { + final baseMessage = { + 'type': 'liveDraft.updated', + 'sessionId': 'session-1', + 'deviceId': 'device-1', + 'clientSeq': 12, + 'updatedFields': ['callsign'], + 'draft': _draft( + version: 5, + values: const {'qth': '杭州'}, + revisions: const {'qth': 8}, + ).toJson(), + }; + + LiveDraftPatchControlAcknowledgement? match( + Map message, + ) => + matchLiveDraftPatchControlAck( + message: message, + sessionId: 'session-1', + deviceId: 'device-1', + clientSeq: 12, + draftId: 'draft-1', + expectedValues: const {'qth': '杭州'}, + expectedRevisions: const {'qth': 7}, + ); + + expect(match(baseMessage), isNull, reason: 'field list must match'); + expect( + match({...baseMessage, 'clientSeq': 11}), + isNull, + reason: 'client sequence must match', + ); + expect( + match({ + ...baseMessage, + 'updatedFields': ['qth'], + 'draft': _draft( + version: 5, + values: const {'qth': '宁波'}, + revisions: const {'qth': 8}, + ).toJson(), + }), + isNull, + reason: 'canonical value must match', + ); + }); + + test('legacy own control can acknowledge by exact value and revision', () { + final acknowledgement = matchLiveDraftPatchControlAck( + message: { + 'type': 'liveDraft.updated', + 'sessionId': 'session-1', + 'deviceId': 'device-1', + 'clientSeq': 2, + 'draft': _draft( + version: 2, + values: const {'callsign': 'BG5CRL'}, + revisions: const {'callsign': 1}, + ).toJson(), + }, + sessionId: 'session-1', + deviceId: 'device-1', + clientSeq: 2, + draftId: 'draft-1', + expectedValues: const {'callsign': 'BG5CRL'}, + expectedRevisions: const {'callsign': 0}, + ); + + expect(acknowledgement, isNotNull); + expect(acknowledgement!.releasedLeases, isEmpty); + }); + test('updated payload projects every supported field without a GET', () { final current = _snapshot(draft: _draft(version: 1)); final values = { @@ -1014,6 +1455,68 @@ void main() { expect(projection.baseRevisions, {'qth': 7}); }); + test('history reuse replaces only affected dirty station fields', () { + final current = _snapshot( + draft: _draft( + version: 4, + values: const { + 'callsign': 'BA4AAA', + 'qth': 'old-qth', + 'device': 'old-device', + 'remarks': 'old-remarks', + }, + revisions: const {'callsign': 2, 'qth': 7, 'device': 2}, + ), + locks: [_lock('device', 'device-lock')], + ); + final local = LiveDraftFieldsDto({ + for (final field in liveDraftFieldNames) + field: current.draft.fields[field], + 'qth': 'uncommitted-qth', + 'device': 'uncommitted-device', + 'remarks': 'keep-my-remarks', + }); + final canonical = _draft( + version: 5, + values: const { + 'callsign': 'BA4AAA', + 'qth': 'history-qth', + 'device': 'history-device', + 'remarks': 'remote-remarks', + }, + revisions: const {'callsign': 2, 'qth': 8, 'device': 3}, + ); + final ordinary = applyLiveDraftControlMessage( + currentSnapshot: current, + currentLocalFields: local, + currentDirtyFields: const {'qth', 'device', 'remarks'}, + currentBaseRevisions: const {'qth': 7, 'device': 2, 'remarks': 0}, + message: { + 'type': 'liveDraft.updated', + 'sessionId': 'session-1', + 'draft': canonical.toJson(), + 'locks': const [], + }, + ); + final reused = applyLiveDraftHistoryReuseProjection( + projection: ordinary, + canonicalDraft: canonical, + historyReuse: const LiveDraftHistoryReuseDto( + previewId: 'preview-1', + candidateId: 'history-1', + affectedFields: {'qth', 'device'}, + ), + locks: const [], + ); + + expect(reused.localFields['qth'], 'history-qth'); + expect(reused.localFields['device'], 'history-device'); + expect(reused.localFields['remarks'], 'keep-my-remarks'); + expect(reused.dirtyFields, {'remarks'}); + expect(reused.baseRevisions, {'remarks': 0}); + expect(reused.snapshot.locks, isEmpty); + }); + test('cleared and committed controls replace the draft generation', () { final previous = _log('previous-log', callsign: 'BA4AAA'); final current = _snapshot( @@ -1454,11 +1957,15 @@ LiveDraftLockDto _lock( expiresAt: expiresAt ?? DateTime.utc(2026, 7, 14), ); -LiveDraftPatchResultDto _patchResult(int clientSeq) { +LiveDraftPatchResultDto _patchResult( + int clientSeq, { + List releasedLeases = const [], +}) { return LiveDraftPatchResultDto( draft: _draft(version: 2), appliedClientSeq: clientSeq, replayed: false, + releasedLeases: releasedLeases, ); } @@ -1467,13 +1974,14 @@ LiveDraftFieldsDto _fields(Map values) => LiveDraftDto _draft({ String draftId = 'draft-1', + String sessionId = 'session-1', required int version, Map values = const {}, Map revisions = const {}, }) => LiveDraftDto( draftId: draftId, - sessionId: 'session-1', + sessionId: sessionId, version: version, fields: _fields(values), fieldRevisions: { diff --git a/test/services/server_api_test.dart b/test/services/server_api_test.dart index 9d78439..a21617e 100644 --- a/test/services/server_api_test.dart +++ b/test/services/server_api_test.dart @@ -845,14 +845,74 @@ void main() { 'field': 'callsign', 'deviceId': 'device-1', }); - return _jsonResponse({'lock': lock}, 201); + return _jsonResponse({ + 'lock': lock, + 'draft': _liveDraftJson(version: 2), + }, 201); case 'POST /api/v1/sessions/session-1/live-draft/locks/lease-1/renew': expect(jsonDecode(request.body), {'deviceId': 'device-1'}); return _jsonResponse({'lock': lock}); case 'DELETE /api/v1/sessions/session-1/live-draft/locks/lease-1': expect(jsonDecode(request.body), {'deviceId': 'device-1'}); return _jsonResponse({'released': true}); + case 'PUT /api/v1/sessions/session-1/live-draft/history-preview': + expect(jsonDecode(request.body), { + 'deviceId': 'device-1', + 'leaseId': 'lease-1', + 'draftId': 'draft-1', + 'callsign': 'K1ABC', + 'candidates': [ + { + 'candidateId': 'history-1', + 'sourceTime': _now, + 'qth': 'Hangzhou', + 'device': '', + 'power': '5W', + 'antenna': '', + 'height': '', + }, + ], + }); + return _jsonResponse({ + 'historyPreview': _historyPreviewJson(), + 'draft': _liveDraftJson(), + 'locks': [lock], + }); + case 'DELETE /api/v1/sessions/session-1/live-draft/history-preview': + expect(jsonDecode(request.body), { + 'deviceId': 'device-1', + 'previewId': 'preview-1', + }); + return _jsonResponse({ + 'cleared': true, + 'historyPreview': null, + }); + case 'POST /api/v1/sessions/session-1/live-draft/history-preview/preview-1/select': + expect(request.headers['idempotency-key'], 'reuse-1'); + expect(jsonDecode(request.body), { + 'deviceId': 'device-1', + 'leaseId': 'lease-1', + 'candidateId': 'history-1', + }); + return _jsonResponse({ + 'draft': _liveDraftJson(version: 2), + 'updatedFields': ['qth', 'power'], + 'releasedLeases': [ + {'field': 'qth', 'leaseId': 'lease-qth'}, + ], + 'locks': [lock], + 'historyPreview': null, + 'historyReuse': { + 'previewId': 'preview-1', + 'candidateId': 'history-1', + 'affectedFields': ['qth', 'power'], + }, + }); case 'PATCH /api/v1/sessions/session-1/live-draft': + expect( + request.headers['prefer'], + 'openlogtool-consume-live-draft-leases', + ); expect(jsonDecode(request.body), { 'deviceId': 'device-1', 'clientSeq': 7, @@ -869,6 +929,9 @@ void main() { 'draft': _liveDraftJson(version: 2), 'appliedClientSeq': 7, 'replayed': false, + 'releasedLeases': [ + {'field': 'callsign', 'leaseId': 'lease-1'}, + ], }); case 'POST /api/v1/sessions/session-1/live-draft/commit': expect(request.headers['idempotency-key'], 'commit-1'); @@ -907,15 +970,13 @@ void main() { final snapshot = await api.getLiveDraft('session-1'); expect(snapshot.draft.createdAt, DateTime.parse(_now)); expect(snapshot.locks.single.sessionId, 'session-1'); - expect( - (await api.acquireLiveDraftLock( - sessionId: 'session-1', - field: 'callsign', - deviceId: 'device-1', - )) - .leaseId, - 'lease-1', + final acquisition = await api.acquireLiveDraftLockWithDraft( + sessionId: 'session-1', + field: 'callsign', + deviceId: 'device-1', ); + expect(acquisition.lock.leaseId, 'lease-1'); + expect(acquisition.draft?.version, 2); await api.renewLiveDraftLock( sessionId: 'session-1', leaseId: 'lease-1', @@ -926,6 +987,40 @@ void main() { leaseId: 'lease-1', deviceId: 'device-1', ); + final preview = await api.publishLiveDraftHistoryPreview( + sessionId: 'session-1', + deviceId: 'device-1', + leaseId: 'lease-1', + draftId: 'draft-1', + callsign: 'K1ABC', + candidates: const [ + LiveDraftHistoryCandidateDto( + candidateId: 'history-1', + sourceTime: _now, + qth: 'Hangzhou', + device: '', + power: '5W', + antenna: '', + height: '', + ), + ], + ); + expect(preview.historyPreview.actor.username, 'alice'); + final reuse = await api.selectLiveDraftHistoryCandidate( + sessionId: 'session-1', + previewId: 'preview-1', + deviceId: 'device-1', + leaseId: 'lease-1', + candidateId: 'history-1', + idempotencyKey: 'reuse-1', + ); + expect(reuse.updatedFields, {'qth', 'power'}); + expect(reuse.releasedLeases.single.field, 'qth'); + await api.clearLiveDraftHistoryPreview( + sessionId: 'session-1', + deviceId: 'device-1', + previewId: 'preview-1', + ); final patched = await api.updateLiveDraft( sessionId: 'session-1', deviceId: 'device-1', @@ -940,6 +1035,7 @@ void main() { ], ); expect(patched.appliedClientSeq, 7); + expect(patched.releasedLeases.single.leaseId, 'lease-1'); final committed = await api.commitLiveDraft( sessionId: 'session-1', deviceId: 'device-1', @@ -1265,6 +1361,26 @@ Map _liveDraftJson({ 'lastUpdatedAt': _now, }; +Map _historyPreviewJson() => { + 'previewId': 'preview-1', + 'draftId': 'draft-1', + 'deviceId': 'device-1', + 'callsign': 'K1ABC', + 'actor': {'userId': 'user-1', 'username': 'alice'}, + 'expiresAt': '2026-07-13T08:00:20.000Z', + 'candidates': [ + { + 'candidateId': 'history-1', + 'sourceTime': _now, + 'qth': 'Hangzhou', + 'device': null, + 'power': '5W', + 'antenna': null, + 'height': null, + }, + ], + }; + ServerApi _api({ required TokenStore store, required http.Client client, diff --git a/test/widgets/callsign_history_field_test.dart b/test/widgets/callsign_history_field_test.dart index 0b64cd7..6338464 100644 --- a/test/widgets/callsign_history_field_test.dart +++ b/test/widgets/callsign_history_field_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:openlogtool/l10n/l10n.dart'; +import 'package:openlogtool/models/live_draft.dart'; import 'package:openlogtool/src/bridge/models/log_entry.dart' as bridge; import 'package:openlogtool/widgets/callsign_history_field.dart'; @@ -42,6 +43,34 @@ List _historyRecords(int count) => List.generate( ), ); +LiveDraftHistoryPreviewDto _remotePreview({ + required DateTime expiresAt, + String previewId = 'preview-1', +}) { + return LiveDraftHistoryPreviewDto( + previewId: previewId, + draftId: 'draft-1', + deviceId: 'remote-device', + callsign: 'BA4AAA', + actor: const LiveDraftActorDto( + userId: 'remote-user', + username: 'Remote scribe', + ), + expiresAt: expiresAt, + candidates: const [ + LiveDraftHistoryCandidateDto( + candidateId: 'remote-history-1', + sourceTime: '2026-07-12T08:15:00Z', + qth: '上海', + device: 'IC-7300', + power: '100W', + antenna: 'DP', + height: '12m', + ), + ], + ); +} + Widget _localizedApp(Widget child) => MaterialApp( localizationsDelegates: const [ AppLocalizations.delegate, @@ -557,4 +586,190 @@ void main() { expect(controllers[3].text, 'QTH7'); expect(overlayFinder, findsNothing); }); + + testWidgets( + 'local candidates stay immediately usable when async publication fails', + (tester) async { + final controllers = List.generate(6, (_) => TextEditingController()); + var reuseCalls = 0; + var closeCalls = 0; + String? publishedCallsign; + List? publishedCandidates; + addTearDown(() { + for (final controller in controllers) { + controller.dispose(); + } + }); + + await tester.pumpWidget( + _localizedApp( + CallsignHistoryField( + callsignController: controllers[0], + deviceController: controllers[1], + antennaController: controllers[2], + qthController: controllers[3], + powerController: controllers[4], + heightController: controllers[5], + label: 'Callsign', + hintText: 'BA4AAA', + historyLoader: (_, __) async => [_historyRecord()], + onLocalCandidatesLoaded: (callsign, candidates) async { + publishedCallsign = callsign; + publishedCandidates = candidates; + throw StateError('offline'); + }, + onLocalPreviewClosed: () => closeCalls += 1, + onReuseRecord: (_) async => reuseCalls += 1, + ), + ), + ); + + await tester.tap(find.byType(TextFormField)); + await tester.enterText(find.byType(TextFormField), 'BA4AAA'); + await tester.pumpAndSettle(); + + expect(publishedCallsign, 'BA4AAA'); + expect(publishedCandidates, hasLength(1)); + expect(find.byKey(const Key('callsign-history-overlay')), findsOneWidget); + expect(find.text('上海 · IC-7300 · 100W · DP · 12m'), findsOneWidget); + + await tester.tap(find.byKey(const Key('callsign-history-local-row-0'))); + await tester.pump(); + + expect(reuseCalls, 1); + expect(closeCalls, 0); + expect(find.byKey(const Key('callsign-history-overlay')), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('input changes and focus loss close only an open local preview', + (tester) async { + final controllers = List.generate(6, (_) => TextEditingController()); + var publishCalls = 0; + var closeCalls = 0; + addTearDown(() { + for (final controller in controllers) { + controller.dispose(); + } + }); + + await tester.pumpWidget( + _localizedApp( + Column( + children: [ + CallsignHistoryField( + callsignController: controllers[0], + deviceController: controllers[1], + antennaController: controllers[2], + qthController: controllers[3], + powerController: controllers[4], + heightController: controllers[5], + label: 'Callsign', + hintText: 'BA4AAA', + historyLoader: (callsign, _) async => + callsign == 'BA4AAA' ? [_historyRecord()] : [], + onLocalCandidatesLoaded: (_, __) async => publishCalls += 1, + onLocalPreviewClosed: () => closeCalls += 1, + ), + Expanded( + child: GestureDetector( + key: const Key('outside-history-field'), + behavior: HitTestBehavior.opaque, + onTap: () {}, + ), + ), + ], + ), + ), + ); + + await tester.tap(find.byType(TextFormField)); + await tester.enterText(find.byType(TextFormField), 'BA4AAA'); + await tester.pumpAndSettle(); + expect(publishCalls, 1); + + await tester.enterText(find.byType(TextFormField), 'BA4AAB'); + await tester.pumpAndSettle(); + expect(closeCalls, 1); + + await tester.enterText(find.byType(TextFormField), 'BA4AAA'); + await tester.pumpAndSettle(); + expect(publishCalls, 2); + await tester.tap(find.byKey(const Key('outside-history-field'))); + await tester.pump(const Duration(milliseconds: 299)); + expect(closeCalls, 1); + await tester.pump(const Duration(milliseconds: 1)); + await tester.pump(); + expect(closeCalls, 2); + }); + + testWidgets( + 'remote candidates are read-only when disabled and disappear at expiry', + (tester) async { + final controllers = List.generate( + 6, + (index) => TextEditingController(text: index == 0 ? 'BA4AAA' : ''), + ); + var reuseCalls = 0; + var closeCalls = 0; + addTearDown(() { + for (final controller in controllers) { + controller.dispose(); + } + }); + + Widget app(LiveDraftHistoryPreviewDto preview) => _localizedApp( + CallsignHistoryField( + callsignController: controllers[0], + deviceController: controllers[1], + antennaController: controllers[2], + qthController: controllers[3], + powerController: controllers[4], + heightController: controllers[5], + label: 'Callsign', + hintText: 'BA4AAA', + enabled: false, + remotePreview: preview, + onLocalPreviewClosed: () => closeCalls += 1, + onReuseRecord: (_) async => reuseCalls += 1, + ), + ); + + await tester.pumpWidget( + app(_remotePreview( + expiresAt: DateTime.now().add(const Duration(seconds: 20)))), + ); + await tester.pump(); + + expect(find.byKey(const Key('callsign-history-overlay')), findsOneWidget); + expect(find.text('上海 · IC-7300 · 100W · DP · 12m'), findsOneWidget); + final remoteRow = tester.widget( + find.byKey(const Key('callsign-history-remote-row-0')), + ); + expect(remoteRow.onTap, isNull); + expect(tester.widget(find.byType(TextFormField)).enabled, + isFalse); + + await tester.pumpWidget( + app( + _remotePreview( + previewId: 'preview-2', + expiresAt: DateTime.now().add(const Duration(seconds: 20)), + ), + ), + ); + await tester.pump(); + expect(find.byKey(const Key('callsign-history-overlay')), findsOneWidget); + expect(closeCalls, 0); + expect(reuseCalls, 0); + expect(controllers.skip(1).map((controller) => controller.text), + everyElement(isEmpty)); + + await tester.pump(const Duration(seconds: 19)); + expect(find.byKey(const Key('callsign-history-overlay')), findsOneWidget); + await tester.pump(const Duration(seconds: 2)); + expect(find.byKey(const Key('callsign-history-overlay')), findsNothing); + expect(closeCalls, 0); + expect(reuseCalls, 0); + }); } diff --git a/test/widgets/log_form_collaboration_test.dart b/test/widgets/log_form_collaboration_test.dart index 231f085..e5ce217 100644 --- a/test/widgets/log_form_collaboration_test.dart +++ b/test/widgets/log_form_collaboration_test.dart @@ -485,6 +485,10 @@ void main() { tester.widget(callsignEditableFinder); await tester.tap(callsignEditableFinder); await tester.pump(); + expect(collaboration.acquiredFields, isEmpty); + + await tester.enterText(callsignEditableFinder, 'BA4AAA'); + await tester.pump(); expect(collaboration.acquiredFields, ['callsign']); final outside = tester.getCenter( @@ -505,6 +509,118 @@ void main() { }, ); + testWidgets( + 'history candidates flush callsign before the 250ms debounce and publish no record time', + (tester) async { + final collaboration = _RecordingCollaborationProvider(); + collaboration.historyPreviewSupported = true; + addTearDown(collaboration.dispose); + + await tester.pumpWidget(_LogFormTestApp(collaboration: collaboration)); + await tester.pumpAndSettle(); + + final callsignField = tester + .widget(find.byType(CallsignHistoryField)); + final editable = find.descendant( + of: find.byType(CallsignHistoryField), + matching: find.byType(EditableText), + ); + await tester.tap(editable); + await tester.enterText(editable, 'BA4AAA'); + await tester.pump(const Duration(milliseconds: 20)); + + await callsignField.onLocalCandidatesLoaded!('BA4AAA', [_historyRecord]); + await tester.pump(); + + expect( + collaboration.historyPreviewEvents.take(2), + orderedEquals(['flush:callsign', 'publish:BA4AAA']), + ); + final candidate = collaboration.publishedHistoryCandidates.single; + expect(candidate.candidateId, 'history-1'); + expect(candidate.sourceTime, '2026-07-12T08:15:00.000Z'); + expect(candidate.reusableValues, { + 'qth': 'Shanghai', + 'device': 'IC-7300', + 'power': '100W', + 'antenna': 'DP', + 'height': '12m', + }); + expect(candidate.toJson(), isNot(contains('time'))); + }, + ); + + testWidgets( + 'explicit remote history reuse cancels only the affected focused edit', + (tester) async { + final collaboration = _RecordingCollaborationProvider( + initialFields: const { + 'time': '', + 'controller': 'BG5CRL', + 'callsign': 'BA4AAA', + 'rstSent': '59', + 'rstRcvd': '59', + 'qth': 'old-qth', + 'remarks': 'keep-remarks', + }, + ); + addTearDown(collaboration.dispose); + + await tester.pumpWidget(_LogFormTestApp(collaboration: collaboration)); + await tester.pumpAndSettle(); + final qthFieldFinder = find.ancestor( + of: find.text('QTH'), + matching: find.byType(TextFormField), + ); + final qthEditable = tester.widget( + find.descendant( + of: qthFieldFinder, + matching: find.byType(EditableText), + ), + ); + + await tester.tap(qthFieldFinder); + await tester.enterText(qthFieldFinder, 'uncommitted-qth'); + await tester.pump(const Duration(milliseconds: 50)); + expect(qthEditable.focusNode.hasFocus, isTrue); + + collaboration.applyRemoteHistoryReuse(const {'qth': 'history-qth'}); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(qthEditable.controller.text, 'history-qth'); + expect(qthEditable.focusNode.hasFocus, isFalse); + expect(collaboration.liveDraftFields['qth'], 'history-qth'); + expect(collaboration.liveDraftFields['remarks'], 'keep-remarks'); + }, + ); + + testWidgets('disposing the form releases a lease acquired by a dirty field', + (tester) async { + final collaboration = _RecordingCollaborationProvider(); + addTearDown(collaboration.dispose); + + await tester.pumpWidget(_LogFormTestApp(collaboration: collaboration)); + await tester.pumpAndSettle(); + final callsign = find.descendant( + of: find.byType(CallsignHistoryField), + matching: find.byType(EditableText), + ); + await tester.tap(callsign); + await tester.enterText(callsign, 'BA4AAA'); + await tester.pump(); + + expect(collaboration.acquiredFields, ['callsign']); + expect(collaboration.ownedLiveDraftLocks, contains('callsign')); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + + expect(collaboration.releasedFields, contains('callsign')); + expect(collaboration.ownedLiveDraftLocks, isNot(contains('callsign'))); + }); + testWidgets( 'idle duplicate warning also clears a collaboration draft atomically', (tester) async { @@ -1215,7 +1331,14 @@ class _RecordingCollaborationProvider extends CollaborationProvider { final List> atomicUpdates = []; final List acquiredFields = []; final List releasedFields = []; + final List historyPreviewEvents = []; + List publishedHistoryCandidates = const []; final Map _ownedLocks = {}; + LiveDraftHistoryPreviewDto? _historyPreview; + int _historyReuseEpoch = 0; + Set _historyReuseAffectedFields = const {}; + bool historyPreviewSupported = false; + bool historySelectionSucceeds = false; Map? committedFields; Completer? atomicGate; Completer? commitGate; @@ -1308,6 +1431,20 @@ class _RecordingCollaborationProvider extends CollaborationProvider { @override List get liveDraftLocks => const []; + @override + LiveDraftHistoryPreviewDto? get liveDraftHistoryPreview => _historyPreview; + + @override + bool get liveDraftHistoryPreviewOwnedHere => + _historyPreview?.deviceId == 'device-1'; + + @override + int get liveDraftHistoryReuseEpoch => _historyReuseEpoch; + + @override + Set get liveDraftHistoryReuseAffectedFields => + _historyReuseAffectedFields; + @override Map get ownedLiveDraftLocks => Map.unmodifiable(_ownedLocks); @@ -1340,6 +1477,20 @@ class _RecordingCollaborationProvider extends CollaborationProvider { _ownedLocks.remove(field); } + @override + Future releaseLiveDraftFieldIfClean( + String field, { + String? expectedLeaseId, + }) async { + final lock = _ownedLocks[field]; + if (lock == null || + (expectedLeaseId != null && lock.leaseId != expectedLeaseId)) { + return; + } + releasedFields.add(field); + _ownedLocks.remove(field); + } + @override Future updateLiveDraftField(String field, String value) async { final error = fieldUpdateError; @@ -1347,6 +1498,122 @@ class _RecordingCollaborationProvider extends CollaborationProvider { replaceDraftField(field, value); } + @override + Future flushLiveDraftField(String field) async { + historyPreviewEvents.add('flush:$field'); + } + + @override + Future publishLiveDraftHistoryPreview({ + required String callsign, + required List candidates, + }) async { + historyPreviewEvents.add('publish:$callsign'); + publishedHistoryCandidates = List.unmodifiable(candidates); + if (!historyPreviewSupported) return null; + _historyPreview = LiveDraftHistoryPreviewDto( + previewId: 'preview-1', + draftId: _snapshot.draft.draftId, + deviceId: 'device-1', + callsign: callsign, + actor: const LiveDraftActorDto( + userId: 'user-1', + username: 'tester', + ), + expiresAt: DateTime.now().add(const Duration(seconds: 20)), + candidates: List.unmodifiable(candidates), + ); + notifyListeners(); + return _historyPreview; + } + + @override + Future clearLiveDraftHistoryPreview({String? expectedPreviewId}) async { + historyPreviewEvents.add('clear'); + if (expectedPreviewId == null || + _historyPreview?.previewId == expectedPreviewId) { + _historyPreview = null; + notifyListeners(); + } + } + + @override + Future selectLiveDraftHistoryCandidate({ + required String previewId, + required String candidateId, + }) async { + historyPreviewEvents.add('select:$candidateId'); + final preview = _historyPreview; + if (!historySelectionSucceeds || + preview == null || + preview.previewId != previewId) { + return false; + } + final candidate = preview.candidates.singleWhere( + (item) => item.candidateId == candidateId, + ); + final affected = candidate.reusableValues.keys.toSet(); + final previous = _snapshot.draft; + _snapshot = LiveDraftSnapshotDto( + draft: LiveDraftDto( + draftId: previous.draftId, + sessionId: previous.sessionId, + version: previous.version + 1, + fields: LiveDraftFieldsDto({ + ...previous.fields.values, + ...candidate.reusableValues, + }), + fieldRevisions: { + for (final field in liveDraftFieldNames) + field: (previous.fieldRevisions[field] ?? 0) + + (affected.contains(field) ? 1 : 0), + }, + lastUpdatedBy: previous.lastUpdatedBy, + createdAt: previous.createdAt, + lastUpdatedAt: DateTime.now().toUtc(), + ), + locks: const [], + currentOrdinal: _snapshot.currentOrdinal, + totalRecords: _snapshot.totalRecords, + previousRecord: _snapshot.previousRecord, + ); + _optimisticFields = null; + _historyPreview = null; + _historyReuseAffectedFields = Set.unmodifiable(affected); + _historyReuseEpoch += 1; + notifyListeners(); + return true; + } + + void applyRemoteHistoryReuse(Map values) { + final previous = _snapshot.draft; + final affected = values.keys.toSet(); + _snapshot = LiveDraftSnapshotDto( + draft: LiveDraftDto( + draftId: previous.draftId, + sessionId: previous.sessionId, + version: previous.version + 1, + fields: LiveDraftFieldsDto({...previous.fields.values, ...values}), + fieldRevisions: { + for (final field in liveDraftFieldNames) + field: (previous.fieldRevisions[field] ?? 0) + + (affected.contains(field) ? 1 : 0), + }, + lastUpdatedBy: previous.lastUpdatedBy, + createdAt: previous.createdAt, + lastUpdatedAt: DateTime.now().toUtc(), + ), + locks: const [], + currentOrdinal: _snapshot.currentOrdinal, + totalRecords: _snapshot.totalRecords, + previousRecord: _snapshot.previousRecord, + ); + _optimisticFields = null; + _historyReuseAffectedFields = Set.unmodifiable(affected); + _historyReuseEpoch += 1; + notifyListeners(); + } + @override Future updateLiveDraftFieldsAtomically( Map updates,