Add stable share fingerprints and near-match conflict routing

This commit is contained in:
Rijad Zuzo
2026-02-19 00:52:20 +01:00
parent d8d96e53e8
commit 0347ee1c0a
6 changed files with 303 additions and 1 deletions
+4
View File
@@ -101,6 +101,10 @@ Behavior notes:
- moment history (`type=whatsapp`) - moment history (`type=whatsapp`)
- ambiguous or missing-identity shares are queued in `Share Inbox` for manual - ambiguous or missing-identity shares are queued in `Share Inbox` for manual
resolution resolution
- near-name conflicts now also route to `Share Inbox` to avoid accidental
profile mismatches
- follow-up routing prefers stable source fingerprint matching (when source
user/thread IDs are available)
- app launch from initial share intent auto-opens the relevant destination: - app launch from initial share intent auto-opens the relevant destination:
- resolved import -> `People` with imported profile selected - resolved import -> `People` with imported profile selected
- unresolved import -> `Share Inbox` - unresolved import -> `Share Inbox`
+28
View File
@@ -41,6 +41,34 @@ navigation:
- `test/features/local/local_repository_test.dart` - `test/features/local/local_repository_test.dart`
- verifies merge rebinding across related records - verifies merge rebinding across related records
## Latest Milestone (2026-02-18): Stable Share Fingerprints + Near-Match Conflict Detection
Strengthened WhatsApp identity mapping to reduce accidental mis-links:
- Added stable source fingerprint strategy:
- `lib/features/local/local_models.dart`
- `SourceProfileLink` now stores optional `sourceFingerprint`
- `SharedInboxEntry` also keeps the same fingerprint when unresolved
- fingerprint is derived from stable source identifiers (`sourceApp` +
source user/thread ids)
- Updated ingest matching order:
- `lib/features/local/local_repository.dart`
- fingerprint match is now the primary lookup path
- backward-compatible fallback to existing user/thread/display matching
- legacy links without stored fingerprint still match by derived fingerprint
- Added near-match conflict handling:
- new inbox reason: `nearProfileConflict`
- fuzzy name similarity heuristic (edit-distance + containment) now detects
likely profile conflicts and routes to Share Inbox for explicit user choice
- Updated Share Inbox UI reason labels:
- `lib/features/share_intake/share_inbox_view.dart`
- Added test coverage:
- `test/features/local/local_repository_test.dart`
- verifies:
- fingerprint persistence on source links
- near-match conflict queueing
- follow-up routing succeeds via fingerprint after manual resolution
## Latest Milestone (2026-02-18): Share Inbox Resolution Flow ## Latest Milestone (2026-02-18): Share Inbox Resolution Flow
Added manual resolution UX and data paths for ambiguous/unidentifiable WhatsApp Added manual resolution UX and data paths for ambiguous/unidentifiable WhatsApp
+17 -1
View File
@@ -335,12 +335,14 @@ class SourceProfileLink {
required this.lastSeenAt, required this.lastSeenAt,
this.sourceUserId, this.sourceUserId,
this.sourceThreadId, this.sourceThreadId,
this.sourceFingerprint,
}); });
final String id; final String id;
final String sourceApp; final String sourceApp;
final String? sourceUserId; final String? sourceUserId;
final String? sourceThreadId; final String? sourceThreadId;
final String? sourceFingerprint;
final String normalizedDisplayName; final String normalizedDisplayName;
final String profileId; final String profileId;
final DateTime firstSeenAt; final DateTime firstSeenAt;
@@ -351,6 +353,7 @@ class SourceProfileLink {
String? sourceApp, String? sourceApp,
String? sourceUserId, String? sourceUserId,
String? sourceThreadId, String? sourceThreadId,
String? sourceFingerprint,
String? normalizedDisplayName, String? normalizedDisplayName,
String? profileId, String? profileId,
DateTime? firstSeenAt, DateTime? firstSeenAt,
@@ -361,6 +364,7 @@ class SourceProfileLink {
sourceApp: sourceApp ?? this.sourceApp, sourceApp: sourceApp ?? this.sourceApp,
sourceUserId: sourceUserId ?? this.sourceUserId, sourceUserId: sourceUserId ?? this.sourceUserId,
sourceThreadId: sourceThreadId ?? this.sourceThreadId, sourceThreadId: sourceThreadId ?? this.sourceThreadId,
sourceFingerprint: sourceFingerprint ?? this.sourceFingerprint,
normalizedDisplayName: normalizedDisplayName:
normalizedDisplayName ?? this.normalizedDisplayName, normalizedDisplayName ?? this.normalizedDisplayName,
profileId: profileId ?? this.profileId, profileId: profileId ?? this.profileId,
@@ -375,6 +379,7 @@ class SourceProfileLink {
'sourceApp': sourceApp, 'sourceApp': sourceApp,
'sourceUserId': sourceUserId, 'sourceUserId': sourceUserId,
'sourceThreadId': sourceThreadId, 'sourceThreadId': sourceThreadId,
'sourceFingerprint': sourceFingerprint,
'normalizedDisplayName': normalizedDisplayName, 'normalizedDisplayName': normalizedDisplayName,
'profileId': profileId, 'profileId': profileId,
'firstSeenAt': firstSeenAt.toUtc().toIso8601String(), 'firstSeenAt': firstSeenAt.toUtc().toIso8601String(),
@@ -388,6 +393,7 @@ class SourceProfileLink {
sourceApp: json['sourceApp'] as String, sourceApp: json['sourceApp'] as String,
sourceUserId: json['sourceUserId'] as String?, sourceUserId: json['sourceUserId'] as String?,
sourceThreadId: json['sourceThreadId'] as String?, sourceThreadId: json['sourceThreadId'] as String?,
sourceFingerprint: json['sourceFingerprint'] as String?,
normalizedDisplayName: json['normalizedDisplayName'] as String? ?? '', normalizedDisplayName: json['normalizedDisplayName'] as String? ?? '',
profileId: json['profileId'] as String, profileId: json['profileId'] as String,
firstSeenAt: DateTime.parse(json['firstSeenAt'] as String).toLocal(), firstSeenAt: DateTime.parse(json['firstSeenAt'] as String).toLocal(),
@@ -480,7 +486,11 @@ class SharedMessageEntry {
} }
} }
enum SharedInboxReason { ambiguousProfileMatch, missingIdentity } enum SharedInboxReason {
ambiguousProfileMatch,
nearProfileConflict,
missingIdentity,
}
@immutable @immutable
class SharedInboxEntry { class SharedInboxEntry {
@@ -493,6 +503,7 @@ class SharedInboxEntry {
required this.reason, required this.reason,
required this.candidateProfileIds, required this.candidateProfileIds,
required this.normalizedDisplayName, required this.normalizedDisplayName,
this.sourceFingerprint,
this.sourceDisplayName, this.sourceDisplayName,
this.sourceUserId, this.sourceUserId,
this.sourceThreadId, this.sourceThreadId,
@@ -506,6 +517,7 @@ class SharedInboxEntry {
final SharedInboxReason reason; final SharedInboxReason reason;
final List<String> candidateProfileIds; final List<String> candidateProfileIds;
final String normalizedDisplayName; final String normalizedDisplayName;
final String? sourceFingerprint;
final String? sourceDisplayName; final String? sourceDisplayName;
final String? sourceUserId; final String? sourceUserId;
final String? sourceThreadId; final String? sourceThreadId;
@@ -519,6 +531,7 @@ class SharedInboxEntry {
SharedInboxReason? reason, SharedInboxReason? reason,
List<String>? candidateProfileIds, List<String>? candidateProfileIds,
String? normalizedDisplayName, String? normalizedDisplayName,
String? sourceFingerprint,
String? sourceDisplayName, String? sourceDisplayName,
String? sourceUserId, String? sourceUserId,
String? sourceThreadId, String? sourceThreadId,
@@ -533,6 +546,7 @@ class SharedInboxEntry {
candidateProfileIds: candidateProfileIds ?? this.candidateProfileIds, candidateProfileIds: candidateProfileIds ?? this.candidateProfileIds,
normalizedDisplayName: normalizedDisplayName:
normalizedDisplayName ?? this.normalizedDisplayName, normalizedDisplayName ?? this.normalizedDisplayName,
sourceFingerprint: sourceFingerprint ?? this.sourceFingerprint,
sourceDisplayName: sourceDisplayName ?? this.sourceDisplayName, sourceDisplayName: sourceDisplayName ?? this.sourceDisplayName,
sourceUserId: sourceUserId ?? this.sourceUserId, sourceUserId: sourceUserId ?? this.sourceUserId,
sourceThreadId: sourceThreadId ?? this.sourceThreadId, sourceThreadId: sourceThreadId ?? this.sourceThreadId,
@@ -549,6 +563,7 @@ class SharedInboxEntry {
'reason': reason.name, 'reason': reason.name,
'candidateProfileIds': candidateProfileIds, 'candidateProfileIds': candidateProfileIds,
'normalizedDisplayName': normalizedDisplayName, 'normalizedDisplayName': normalizedDisplayName,
'sourceFingerprint': sourceFingerprint,
'sourceDisplayName': sourceDisplayName, 'sourceDisplayName': sourceDisplayName,
'sourceUserId': sourceUserId, 'sourceUserId': sourceUserId,
'sourceThreadId': sourceThreadId, 'sourceThreadId': sourceThreadId,
@@ -573,6 +588,7 @@ class SharedInboxEntry {
.map((dynamic id) => '$id') .map((dynamic id) => '$id')
.toList(growable: false), .toList(growable: false),
normalizedDisplayName: json['normalizedDisplayName'] as String? ?? '', normalizedDisplayName: json['normalizedDisplayName'] as String? ?? '',
sourceFingerprint: json['sourceFingerprint'] as String?,
sourceDisplayName: json['sourceDisplayName'] as String?, sourceDisplayName: json['sourceDisplayName'] as String?,
sourceUserId: json['sourceUserId'] as String?, sourceUserId: json['sourceUserId'] as String?,
sourceThreadId: json['sourceThreadId'] as String?, sourceThreadId: json['sourceThreadId'] as String?,
+171
View File
@@ -183,10 +183,16 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
); );
final String? sourceUserId = _trimToNull(input.sourceUserId); final String? sourceUserId = _trimToNull(input.sourceUserId);
final String? sourceThreadId = _trimToNull(input.sourceThreadId); final String? sourceThreadId = _trimToNull(input.sourceThreadId);
final String? sourceFingerprint = _buildStableSourceFingerprint(
sourceApp: sourceApp,
sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId,
);
final SourceProfileLink? matchedLink = _findExistingSourceLink( final SourceProfileLink? matchedLink = _findExistingSourceLink(
links: current.sourceLinks, links: current.sourceLinks,
sourceApp: sourceApp, sourceApp: sourceApp,
sourceFingerprint: sourceFingerprint,
sourceUserId: sourceUserId, sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId, sourceThreadId: sourceThreadId,
normalizedDisplayName: normalizedDisplayName, normalizedDisplayName: normalizedDisplayName,
@@ -215,6 +221,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
sourceDisplayName: sourceDisplayName, sourceDisplayName: sourceDisplayName,
sourceUserId: sourceUserId, sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId, sourceThreadId: sourceThreadId,
sourceFingerprint: sourceFingerprint,
normalizedDisplayName: normalizedDisplayName, normalizedDisplayName: normalizedDisplayName,
reason: SharedInboxReason.ambiguousProfileMatch, reason: SharedInboxReason.ambiguousProfileMatch,
candidateProfileIds: matchingPeople candidateProfileIds: matchingPeople
@@ -223,6 +230,31 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
); );
} }
final List<PersonProfile> nearMatchPeople = resolvedPerson == null
? _findNearMatchPeople(
current.people,
normalizedDisplayName: normalizedDisplayName,
)
: const <PersonProfile>[];
if (resolvedPerson == null && nearMatchPeople.isNotEmpty) {
return _queueSharedInbox(
current: current,
sourceApp: sourceApp,
messageText: trimmedMessage,
sharedAt: sharedAt,
receivedAt: now,
sourceDisplayName: sourceDisplayName,
sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId,
sourceFingerprint: sourceFingerprint,
normalizedDisplayName: normalizedDisplayName,
reason: SharedInboxReason.nearProfileConflict,
candidateProfileIds: nearMatchPeople
.map((PersonProfile person) => person.id)
.toList(growable: false),
);
}
bool createdProfile = false; bool createdProfile = false;
final List<PersonProfile> nextPeople = current.people.toList( final List<PersonProfile> nextPeople = current.people.toList(
growable: true, growable: true,
@@ -243,6 +275,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
sourceDisplayName: sourceDisplayName, sourceDisplayName: sourceDisplayName,
sourceUserId: sourceUserId, sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId, sourceThreadId: sourceThreadId,
sourceFingerprint: sourceFingerprint,
normalizedDisplayName: normalizedDisplayName, normalizedDisplayName: normalizedDisplayName,
reason: SharedInboxReason.missingIdentity, reason: SharedInboxReason.missingIdentity,
candidateProfileIds: const <String>[], candidateProfileIds: const <String>[],
@@ -270,6 +303,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
sourceDisplayName: sourceDisplayName, sourceDisplayName: sourceDisplayName,
sourceUserId: sourceUserId, sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId, sourceThreadId: sourceThreadId,
sourceFingerprint: sourceFingerprint,
normalizedDisplayName: normalizedDisplayName, normalizedDisplayName: normalizedDisplayName,
resolvedPerson: resolvedPerson, resolvedPerson: resolvedPerson,
matchedLink: matchedLink, matchedLink: matchedLink,
@@ -299,6 +333,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
final SourceProfileLink? matchedLink = _findExistingSourceLink( final SourceProfileLink? matchedLink = _findExistingSourceLink(
links: current.sourceLinks, links: current.sourceLinks,
sourceApp: entry.sourceApp, sourceApp: entry.sourceApp,
sourceFingerprint: entry.sourceFingerprint,
sourceUserId: entry.sourceUserId, sourceUserId: entry.sourceUserId,
sourceThreadId: entry.sourceThreadId, sourceThreadId: entry.sourceThreadId,
normalizedDisplayName: entry.normalizedDisplayName, normalizedDisplayName: entry.normalizedDisplayName,
@@ -313,6 +348,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
sourceDisplayName: entry.sourceDisplayName, sourceDisplayName: entry.sourceDisplayName,
sourceUserId: entry.sourceUserId, sourceUserId: entry.sourceUserId,
sourceThreadId: entry.sourceThreadId, sourceThreadId: entry.sourceThreadId,
sourceFingerprint: entry.sourceFingerprint,
normalizedDisplayName: entry.normalizedDisplayName, normalizedDisplayName: entry.normalizedDisplayName,
resolvedPerson: resolvedPerson, resolvedPerson: resolvedPerson,
matchedLink: matchedLink, matchedLink: matchedLink,
@@ -359,6 +395,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
final SourceProfileLink? matchedLink = _findExistingSourceLink( final SourceProfileLink? matchedLink = _findExistingSourceLink(
links: current.sourceLinks, links: current.sourceLinks,
sourceApp: entry.sourceApp, sourceApp: entry.sourceApp,
sourceFingerprint: entry.sourceFingerprint,
sourceUserId: entry.sourceUserId, sourceUserId: entry.sourceUserId,
sourceThreadId: entry.sourceThreadId, sourceThreadId: entry.sourceThreadId,
normalizedDisplayName: entry.normalizedDisplayName, normalizedDisplayName: entry.normalizedDisplayName,
@@ -378,6 +415,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
sourceDisplayName: entry.sourceDisplayName, sourceDisplayName: entry.sourceDisplayName,
sourceUserId: entry.sourceUserId, sourceUserId: entry.sourceUserId,
sourceThreadId: entry.sourceThreadId, sourceThreadId: entry.sourceThreadId,
sourceFingerprint: entry.sourceFingerprint,
normalizedDisplayName: entry.normalizedDisplayName, normalizedDisplayName: entry.normalizedDisplayName,
resolvedPerson: createdPerson, resolvedPerson: createdPerson,
matchedLink: matchedLink, matchedLink: matchedLink,
@@ -1489,6 +1527,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
required SharedInboxReason reason, required SharedInboxReason reason,
required List<String> candidateProfileIds, required List<String> candidateProfileIds,
required String normalizedDisplayName, required String normalizedDisplayName,
String? sourceFingerprint,
String? sourceDisplayName, String? sourceDisplayName,
String? sourceUserId, String? sourceUserId,
String? sourceThreadId, String? sourceThreadId,
@@ -1505,6 +1544,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
reason: reason, reason: reason,
candidateProfileIds: candidateProfileIds, candidateProfileIds: candidateProfileIds,
normalizedDisplayName: normalizedDisplayName, normalizedDisplayName: normalizedDisplayName,
sourceFingerprint: sourceFingerprint,
sourceDisplayName: sourceDisplayName, sourceDisplayName: sourceDisplayName,
sourceUserId: sourceUserId, sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId, sourceThreadId: sourceThreadId,
@@ -1526,6 +1566,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
required DateTime sharedAt, required DateTime sharedAt,
required DateTime importedAt, required DateTime importedAt,
required String normalizedDisplayName, required String normalizedDisplayName,
required String? sourceFingerprint,
required PersonProfile resolvedPerson, required PersonProfile resolvedPerson,
required bool createdProfile, required bool createdProfile,
required List<PersonProfile> people, required List<PersonProfile> people,
@@ -1548,6 +1589,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
sourceApp: sourceApp, sourceApp: sourceApp,
sourceUserId: sourceUserId, sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId, sourceThreadId: sourceThreadId,
sourceFingerprint: sourceFingerprint,
normalizedDisplayName: normalizedDisplayName, normalizedDisplayName: normalizedDisplayName,
profileId: resolvedPerson.id, profileId: resolvedPerson.id,
firstSeenAt: sharedAt, firstSeenAt: sharedAt,
@@ -1562,6 +1604,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
nextLinks[index] = matchedLink.copyWith( nextLinks[index] = matchedLink.copyWith(
sourceUserId: sourceUserId ?? matchedLink.sourceUserId, sourceUserId: sourceUserId ?? matchedLink.sourceUserId,
sourceThreadId: sourceThreadId ?? matchedLink.sourceThreadId, sourceThreadId: sourceThreadId ?? matchedLink.sourceThreadId,
sourceFingerprint: sourceFingerprint ?? matchedLink.sourceFingerprint,
normalizedDisplayName: normalizedDisplayName.isEmpty normalizedDisplayName: normalizedDisplayName.isEmpty
? matchedLink.normalizedDisplayName ? matchedLink.normalizedDisplayName
: normalizedDisplayName, : normalizedDisplayName,
@@ -1655,10 +1698,27 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
SourceProfileLink? _findExistingSourceLink({ SourceProfileLink? _findExistingSourceLink({
required List<SourceProfileLink> links, required List<SourceProfileLink> links,
required String sourceApp, required String sourceApp,
required String? sourceFingerprint,
required String? sourceUserId, required String? sourceUserId,
required String? sourceThreadId, required String? sourceThreadId,
required String normalizedDisplayName, required String normalizedDisplayName,
}) { }) {
if (sourceFingerprint != null && sourceFingerprint.isNotEmpty) {
for (final SourceProfileLink link in links) {
final String? linkFingerprint =
link.sourceFingerprint ??
_buildStableSourceFingerprint(
sourceApp: link.sourceApp,
sourceUserId: link.sourceUserId,
sourceThreadId: link.sourceThreadId,
);
if (link.sourceApp == sourceApp &&
linkFingerprint == sourceFingerprint) {
return link;
}
}
}
if (sourceUserId != null && sourceUserId.isNotEmpty) { if (sourceUserId != null && sourceUserId.isNotEmpty) {
for (final SourceProfileLink link in links) { for (final SourceProfileLink link in links) {
if (link.sourceApp == sourceApp && link.sourceUserId == sourceUserId) { if (link.sourceApp == sourceApp && link.sourceUserId == sourceUserId) {
@@ -1709,6 +1769,98 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
.toList(growable: false); .toList(growable: false);
} }
List<PersonProfile> _findNearMatchPeople(
List<PersonProfile> people, {
required String normalizedDisplayName,
}) {
if (normalizedDisplayName.isEmpty) {
return const <PersonProfile>[];
}
final Map<String, int> distanceById = <String, int>{};
final List<PersonProfile> candidates = <PersonProfile>[];
for (final PersonProfile person in people) {
final String personName = _normalizeDisplayName(person.name);
if (personName.isEmpty || personName == normalizedDisplayName) {
continue;
}
final int distance = _levenshteinDistance(
normalizedDisplayName,
personName,
);
if (_isNearDisplayName(
normalizedDisplayName,
personName,
distance: distance,
)) {
distanceById[person.id] = distance;
candidates.add(person);
}
}
candidates.sort((PersonProfile a, PersonProfile b) {
final int left = distanceById[a.id] ?? 999;
final int right = distanceById[b.id] ?? 999;
if (left != right) {
return left.compareTo(right);
}
return a.name.length.compareTo(b.name.length);
});
if (candidates.length <= 5) {
return candidates;
}
return candidates.take(5).toList(growable: false);
}
bool _isNearDisplayName(String left, String right, {required int distance}) {
final int maxLength = left.length > right.length
? left.length
: right.length;
final int minLength = left.length < right.length
? left.length
: right.length;
if (minLength >= 4 && (left.contains(right) || right.contains(left))) {
return true;
}
final int threshold = maxLength >= 14 ? 3 : 2;
if (distance <= threshold) {
return true;
}
return maxLength >= 8 && (distance / maxLength) <= 0.22;
}
int _levenshteinDistance(String left, String right) {
if (left == right) {
return 0;
}
if (left.isEmpty) {
return right.length;
}
if (right.isEmpty) {
return left.length;
}
List<int> previous = List<int>.generate(
right.length + 1,
(int index) => index,
growable: false,
);
for (int i = 1; i <= left.length; i += 1) {
final List<int> current = List<int>.filled(right.length + 1, 0);
current[0] = i;
for (int j = 1; j <= right.length; j += 1) {
final int substitutionCost = left[i - 1] == right[j - 1] ? 0 : 1;
final int deletion = previous[j] + 1;
final int insertion = current[j - 1] + 1;
final int substitution = previous[j - 1] + substitutionCost;
final int value = deletion < insertion ? deletion : insertion;
current[j] = value < substitution ? value : substitution;
}
previous = current;
}
return previous[right.length];
}
SharedInboxEntry _requireSharedInboxEntry( SharedInboxEntry _requireSharedInboxEntry(
LocalDataState current, LocalDataState current,
String inboxEntryId, String inboxEntryId,
@@ -1731,6 +1883,25 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
_trimToNull(sourceThreadId); _trimToNull(sourceThreadId);
} }
String? _buildStableSourceFingerprint({
required String sourceApp,
required String? sourceUserId,
required String? sourceThreadId,
}) {
final String? userId = _trimToNull(sourceUserId);
final String? threadId = _trimToNull(sourceThreadId);
if (userId == null && threadId == null) {
return null;
}
final List<String> parts = <String>[
sourceApp.trim().toLowerCase(),
if (userId != null) 'u:${userId.toLowerCase()}',
if (threadId != null) 't:${threadId.toLowerCase()}',
];
return parts.join('|');
}
String _normalizeDisplayName(String? raw) { String _normalizeDisplayName(String? raw) {
if (raw == null) { if (raw == null) {
return ''; return '';
@@ -291,6 +291,7 @@ class _InboxEntryCard extends StatelessWidget {
final bool hasCandidates = suggestedMatches.isNotEmpty; final bool hasCandidates = suggestedMatches.isNotEmpty;
final String reasonLabel = switch (entry.reason) { final String reasonLabel = switch (entry.reason) {
SharedInboxReason.ambiguousProfileMatch => 'Ambiguous match', SharedInboxReason.ambiguousProfileMatch => 'Ambiguous match',
SharedInboxReason.nearProfileConflict => 'Near match conflict',
SharedInboxReason.missingIdentity => 'Missing identity', SharedInboxReason.missingIdentity => 'Missing identity',
}; };
@@ -106,6 +106,10 @@ void main() {
); );
expect(first.createdProfile, isTrue); expect(first.createdProfile, isTrue);
expect(afterFirst.people.length, before.people.length + 1); expect(afterFirst.people.length, before.people.length + 1);
final SourceProfileLink createdLink = afterFirst.sourceLinks.firstWhere(
(SourceProfileLink link) => link.profileId == first.profileId,
);
expect(createdLink.sourceFingerprint, 'whatsapp|u:wa:taylor quinn');
expect( expect(
afterFirst.sourceLinks.any( afterFirst.sourceLinks.any(
(SourceProfileLink link) => link.profileId == first.profileId, (SourceProfileLink link) => link.profileId == first.profileId,
@@ -144,6 +148,84 @@ void main() {
}, },
); );
test(
'queues near-match conflict and then uses fingerprint mapping for follow-up shares',
() async {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
await container.read(localRepositoryProvider.future);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Marek Havel',
relationship: 'Friend',
notes: '',
tags: const <String>[],
);
final LocalDataState peopleState = await container.read(
localRepositoryProvider.future,
);
final String marekId = peopleState.people
.firstWhere((PersonProfile person) => person.name == 'Marek Havel')
.id;
final SharedMessageIngestResult queued = await container
.read(localRepositoryProvider.notifier)
.ingestSharedMessage(
const SharedMessageIngestInput(
sourceApp: 'whatsapp',
sourceDisplayName: 'Marek Havl',
sourceUserId: 'wa:marek_882',
messageText: 'Saw this and thought of you.',
),
);
LocalDataState afterQueue = await container.read(
localRepositoryProvider.future,
);
expect(queued.isQueuedForResolution, isTrue);
expect(
afterQueue.sharedInbox.first.reason,
SharedInboxReason.nearProfileConflict,
);
expect(
afterQueue.sharedInbox.first.candidateProfileIds,
contains(marekId),
);
final SharedMessageIngestResult resolved = await container
.read(localRepositoryProvider.notifier)
.resolveSharedInboxToExistingProfile(
inboxEntryId: queued.inboxEntryId!,
profileId: marekId,
);
expect(resolved.profileId, marekId);
final SharedMessageIngestResult followUp = await container
.read(localRepositoryProvider.notifier)
.ingestSharedMessage(
const SharedMessageIngestInput(
sourceApp: 'whatsapp',
sourceDisplayName: 'Alias Name',
sourceUserId: 'wa:marek_882',
messageText: 'Second follow-up message.',
),
);
afterQueue = await container.read(localRepositoryProvider.future);
expect(followUp.isQueuedForResolution, isFalse);
expect(followUp.profileId, marekId);
expect(afterQueue.sharedInbox, isEmpty);
expect(
afterQueue.sourceLinks.any(
(SourceProfileLink link) =>
link.profileId == marekId &&
link.sourceFingerprint == 'whatsapp|u:wa:marek_882',
),
isTrue,
);
},
);
test( test(
'queues ambiguous shared message into inbox for manual resolution', 'queues ambiguous shared message into inbox for manual resolution',
() async { () async {