Implement iPhone password search
This commit is contained in:
@@ -87,6 +87,8 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
|
||||
let root: UIViewController = switch page.tab {
|
||||
case .passwords:
|
||||
PasswordDirectoryViewController(shellPage: page, authentication: authentication)
|
||||
case .search:
|
||||
PasswordSearchViewController(shellPage: page, authentication: authentication)
|
||||
case .totp:
|
||||
TotpListViewController(shellPage: page, authentication: authentication)
|
||||
case .preferences:
|
||||
@@ -2307,6 +2309,221 @@ private extension UInt64 {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class PasswordSearchViewController: UITableViewController, MobileTabRoot,
|
||||
UISearchResultsUpdating
|
||||
{
|
||||
fileprivate let shellTab = MobileTab.search
|
||||
private let authentication: MobileAuthentication?
|
||||
private let searchController = UISearchController(searchResultsController: nil)
|
||||
private var shellPage: MobilePage
|
||||
private var searchPage: MobilePasswordPage?
|
||||
private var searchTask: Task<Void, Never>?
|
||||
private var shellTask: Task<Void, Never>?
|
||||
private var generation = 0
|
||||
|
||||
init(shellPage: MobilePage, authentication: MobileAuthentication?) {
|
||||
self.shellPage = shellPage
|
||||
self.authentication = authentication
|
||||
super.init(style: .insetGrouped)
|
||||
title = shellPage.title
|
||||
navigationItem.largeTitleDisplayMode = .always
|
||||
searchController.searchResultsUpdater = self
|
||||
searchController.obscuresBackgroundDuringPresentation = false
|
||||
searchController.searchBar.placeholder = "Entry names and folders"
|
||||
searchController.searchBar.autocapitalizationType = .none
|
||||
searchController.searchBar.autocorrectionType = .no
|
||||
searchController.searchBar.spellCheckingType = .no
|
||||
navigationItem.searchController = searchController
|
||||
navigationItem.hidesSearchBarWhenScrolling = false
|
||||
definesPresentationContext = true
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(localStoreDidChange),
|
||||
name: .ironStorageLocalStoreDidChange,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
deinit {
|
||||
searchTask?.cancel()
|
||||
shellTask?.cancel()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
apply(shellPage)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
reloadShell()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
if searchController.searchBar.text?.isEmpty != false {
|
||||
searchController.isActive = true
|
||||
searchController.searchBar.becomeFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
searchPage?.rows.isEmpty == false ? 1 : 0
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
searchPage?.rows.count ?? 0
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||
guard let row = searchPage?.rows[indexPath.row] else { return cell }
|
||||
var content = cell.defaultContentConfiguration()
|
||||
content.image = UIImage(systemName: row.systemImage)
|
||||
content.text = row.title
|
||||
content.secondaryText = row.detail
|
||||
content.textProperties.numberOfLines = 2
|
||||
content.secondaryTextProperties.numberOfLines = 2
|
||||
cell.contentConfiguration = content
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
cell.accessibilityLabel = "\(row.title), in \(row.detail)"
|
||||
cell.accessibilityHint = "Opens the locked password viewer."
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard let row = searchPage?.rows[indexPath.row] else { return }
|
||||
navigationController?.pushViewController(
|
||||
LockedPasswordViewController(entry: row, authentication: authentication),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
|
||||
func updateSearchResults(for searchController: UISearchController) {
|
||||
search(searchController.searchBar.text ?? "")
|
||||
}
|
||||
|
||||
@objc private func localStoreDidChange() {
|
||||
guard viewIfLoaded?.window != nil else { return }
|
||||
search(searchController.searchBar.text ?? "")
|
||||
}
|
||||
|
||||
private func reloadShell() {
|
||||
shellTask?.cancel()
|
||||
shellTask = Task { [weak self] in
|
||||
let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value
|
||||
guard
|
||||
!Task.isCancelled,
|
||||
let self,
|
||||
let page = shell.pages.first(where: { $0.tab == .search })
|
||||
else { return }
|
||||
apply(page)
|
||||
}
|
||||
}
|
||||
|
||||
private func apply(_ page: MobilePage) {
|
||||
shellPage = page
|
||||
title = page.title
|
||||
guard page.state == .ready else {
|
||||
searchTask?.cancel()
|
||||
searchPage = nil
|
||||
tableView.reloadData()
|
||||
var configuration = page.state == .loading
|
||||
? UIContentUnavailableConfiguration.loading()
|
||||
: UIContentUnavailableConfiguration.empty()
|
||||
configuration.image = page.state == .loading
|
||||
? nil
|
||||
: UIImage(systemName: page.state == .error ? "exclamationmark.triangle" : "lock.shield")
|
||||
configuration.text = page.stateTitle
|
||||
configuration.secondaryText = page.stateDetail
|
||||
contentUnavailableConfiguration = configuration
|
||||
return
|
||||
}
|
||||
search(searchController.searchBar.text ?? "")
|
||||
}
|
||||
|
||||
private func search(_ query: String) {
|
||||
generation += 1
|
||||
let currentGeneration = generation
|
||||
searchTask?.cancel()
|
||||
guard shellPage.state == .ready else { return }
|
||||
guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
searchPage = nil
|
||||
tableView.reloadData()
|
||||
var configuration = UIContentUnavailableConfiguration.empty()
|
||||
configuration.image = UIImage(systemName: "magnifyingglass")
|
||||
configuration.text = "Search Passwords"
|
||||
configuration.secondaryText = "Search by entry name or folder."
|
||||
contentUnavailableConfiguration = configuration
|
||||
return
|
||||
}
|
||||
|
||||
searchPage = nil
|
||||
tableView.reloadData()
|
||||
var loading = UIContentUnavailableConfiguration.loading()
|
||||
loading.text = "Searching Passwords"
|
||||
contentUnavailableConfiguration = loading
|
||||
searchTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
return Result<MobilePasswordPage, PasswordFailure>.success(
|
||||
try mobilePasswordSearch(query: query)
|
||||
)
|
||||
} catch let error as MobilePasswordFfiError {
|
||||
return .failure(PasswordFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
guard !Task.isCancelled, let self, currentGeneration == generation else { return }
|
||||
finish(result, query: query)
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(
|
||||
_ result: Result<MobilePasswordPage, PasswordFailure>,
|
||||
query: String
|
||||
) {
|
||||
switch result {
|
||||
case let .success(page):
|
||||
searchPage = page
|
||||
tableView.reloadData()
|
||||
if page.rows.isEmpty {
|
||||
var configuration = UIContentUnavailableConfiguration.empty()
|
||||
configuration.image = UIImage(systemName: "magnifyingglass")
|
||||
configuration.text = "No Results"
|
||||
configuration.secondaryText = "No password entries match “\(query)”."
|
||||
contentUnavailableConfiguration = configuration
|
||||
} else {
|
||||
contentUnavailableConfiguration = nil
|
||||
UIAccessibility.post(
|
||||
notification: .announcement,
|
||||
argument: "\(page.rows.count) password results"
|
||||
)
|
||||
}
|
||||
case let .failure(failure):
|
||||
searchPage = nil
|
||||
tableView.reloadData()
|
||||
var configuration = UIContentUnavailableConfiguration.empty()
|
||||
configuration.image = UIImage(systemName: "exclamationmark.triangle")
|
||||
configuration.text = failure.title
|
||||
configuration.secondaryText = failure.detail
|
||||
contentUnavailableConfiguration = configuration
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class PasswordDirectoryViewController: UITableViewController, MobileTabRoot {
|
||||
fileprivate let shellTab = MobileTab.passwords
|
||||
|
||||
Reference in New Issue
Block a user