From 4ba2c8e6abc6f33df562ba8bca3a7424e07b7d54 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 27 Oct 2025 12:56:00 +0100 Subject: [PATCH] Update native iOS search filter to use AND/OR (#1298) --- .../Selection/PasskeyProviderView.swift | 66 ++++++++++--------- .../Selection/Utils/CredentialFilter.swift | 40 ++++++----- 2 files changed, 58 insertions(+), 48 deletions(-) diff --git a/apps/mobile-app/ios/VaultUI/Selection/PasskeyProviderView.swift b/apps/mobile-app/ios/VaultUI/Selection/PasskeyProviderView.swift index d7cd5b3c1..2d10565ab 100644 --- a/apps/mobile-app/ios/VaultUI/Selection/PasskeyProviderView.swift +++ b/apps/mobile-app/ios/VaultUI/Selection/PasskeyProviderView.swift @@ -227,38 +227,44 @@ public class PasskeyProviderViewModel: ObservableObject { } func filterCredentials() { - if searchText.isEmpty { + let lowercasedSearch = searchText.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + + if lowercasedSearch.isEmpty { filteredCredentials = credentials - } else { - let lowercasedSearch = searchText.lowercased() - filteredCredentials = credentials.filter { credential in - // Filter by service name - if let serviceName = credential.service.name?.lowercased(), - serviceName.contains(lowercasedSearch) { - return true + return + } + + // Split search term into words for AND search + let searchWords = lowercasedSearch + .components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + + if searchWords.isEmpty { + filteredCredentials = credentials + return + } + + // Filter credentials where ALL search words match (each in at least one field) + filteredCredentials = credentials.filter { credential in + // Prepare searchable fields including passkey rpIds + var searchableFields = [ + credential.service.name?.lowercased() ?? "", + credential.service.url?.lowercased() ?? "", + credential.username?.lowercased() ?? "", + credential.alias?.email?.lowercased() ?? "", + credential.notes?.lowercased() ?? "" + ] + + // Add passkey rpIds to searchable fields + if let passkeys = credential.passkeys { + searchableFields.append(contentsOf: passkeys.map { $0.rpId.lowercased() }) + } + + // All search words must be found (each in at least one field) + return searchWords.allSatisfy { word in + searchableFields.contains { field in + field.contains(word) } - // Filter by service URL - if let serviceUrl = credential.service.url?.lowercased(), - serviceUrl.contains(lowercasedSearch) { - return true - } - // Filter by username - if let username = credential.username?.lowercased(), - username.contains(lowercasedSearch) { - return true - } - // Filter by email - if let email = credential.alias?.email?.lowercased(), - email.contains(lowercasedSearch) { - return true - } - // Filter by passkey rpId - if let passkeys = credential.passkeys { - return passkeys.contains { passkey in - passkey.rpId.lowercased().contains(lowercasedSearch) - } - } - return false } } } diff --git a/apps/mobile-app/ios/VaultUI/Selection/Utils/CredentialFilter.swift b/apps/mobile-app/ios/VaultUI/Selection/Utils/CredentialFilter.swift index 99b7e3337..ceccc95f5 100644 --- a/apps/mobile-app/ios/VaultUI/Selection/Utils/CredentialFilter.swift +++ b/apps/mobile-app/ios/VaultUI/Selection/Utils/CredentialFilter.swift @@ -231,30 +231,34 @@ public class CredentialFilter { return Array(matches) } else { - // Non-URL fallback: Extract words from search text for better matching - let searchWords = extractWords(from: searchText) + // Non-URL fallback: Multi-word AND search for better matching + let lowercasedSearch = searchText.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + + // Split search term into words for AND search + let searchWords = lowercasedSearch + .components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } if searchWords.isEmpty { - // If no meaningful words after extraction, fall back to simple contains - let lowercasedSearch = searchText.lowercased() - return credentials.filter { credential in - (credential.service.name?.lowercased().contains(lowercasedSearch) ?? false) || - (credential.username?.lowercased().contains(lowercasedSearch) ?? false) || - (credential.notes?.lowercased().contains(lowercasedSearch) ?? false) - } + return credentials } - // Match using extracted words + // Filter credentials where ALL search words match (each in at least one field) return credentials.filter { credential in - let serviceNameWords = credential.service.name.map { extractWords(from: $0) } ?? [] - let usernameWords = credential.username.map { extractWords(from: $0) } ?? [] - let notesWords = credential.notes.map { extractWords(from: $0) } ?? [] + // Prepare searchable fields + let searchableFields = [ + credential.service.name?.lowercased() ?? "", + credential.username?.lowercased() ?? "", + credential.alias?.email?.lowercased() ?? "", + credential.service.url?.lowercased() ?? "", + credential.notes?.lowercased() ?? "" + ] - // Check if any search word matches any credential word exactly - return searchWords.contains { searchWord in - serviceNameWords.contains(searchWord) || - usernameWords.contains(searchWord) || - notesWords.contains(searchWord) + // All search words must be found (each in at least one field) + return searchWords.allSatisfy { word in + searchableFields.contains { field in + field.contains(word) + } } } }