Files
passforios-mirror/pass/Controllers/PasswordDetailTableViewController.swift
Mingshen SunandClaude Sonnet 5 d9cfbc108b Fix user field not displayed in password detail view
setTableData() checked password.username twice instead of checking
password.user for the User row, so the user field was silently
dropped whenever username was absent (issue #611).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 22:28:01 -07:00

621 lines
26 KiB
Swift

//
// PasswordDetailTableViewController.swift
// pass
//
// Created by Mingshen Sun on 2/2/2017.
// Copyright © 2017 Bob Sun. All rights reserved.
//
import FavIcon
import Gopenpgp
import passAutoFillExtension
import passKit
import SVProgressHUD
import UIKit
import YubiKit
class PasswordDetailTableViewController: UITableViewController, UIGestureRecognizerDelegate, AlertPresenting {
var passwordEntity: PasswordEntity? {
didSet {
passwordPath = passwordEntity?.path
}
}
var password: Password?
private var passwordImage: UIImage?
private var oneTimePasswordIndexPath: IndexPath?
private var shouldPopCurrentView = false
private let passwordStore = PasswordStore.shared
// preserve path so it can be reloaded even if the passwordEntity is deleted during the update process
private var passwordPath: String?
private lazy var editUIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .edit, target: self, action: #selector(pressEdit))
private struct TableSection {
var type: PasswordDetailTableViewControllerSectionType
var header: String?
var item: [AdditionField] = []
init(type: PasswordDetailTableViewControllerSectionType, header: String? = nil) {
self.type = type
self.header = header
}
}
private var tableData = [TableSection]()
private enum PasswordDetailTableViewControllerSectionType {
case name, main, addition, misc
}
private var passwordYubiKeyDecryptor = PasswordYubiKeyDecryptor()
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UINib(nibName: "LabelTableViewCell", bundle: nil), forCellReuseIdentifier: "labelCell")
tableView.register(UINib(nibName: "PasswordDetailTitleTableViewCell", bundle: nil), forCellReuseIdentifier: "passwordDetailTitleTableViewCell")
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(tapMenu))
tapGesture.cancelsTouchesInView = false
tableView.addGestureRecognizer(tapGesture)
tapGesture.delegate = self
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 52
let adjustForTabbarInsets = UIEdgeInsets(top: 0, left: 0, bottom: tabBarController!.tabBar.frame.height, right: 0)
tableView.contentInset = adjustForTabbarInsets
tableView.scrollIndicatorInsets = adjustForTabbarInsets
editUIBarButtonItem.isEnabled = false
navigationItem.rightBarButtonItem = editUIBarButtonItem
navigationItem.largeTitleDisplayMode = .never
if let imageData = passwordEntity?.image {
let image = UIImage(data: imageData as Data)
passwordImage = image
}
decryptThenShowPassword()
setupOneTimePasswordAutoRefresh()
// pop the current view because this password might be "discarded"
NotificationCenter.default.addObserver(self, selector: #selector(setShouldPopCurrentView), name: .passwordStoreChangeDiscarded, object: nil)
// reset the data table if the disaply settings have been changed
NotificationCenter.default.addObserver(self, selector: #selector(decryptThenShowPasswordSelector), name: .passwordDetailDisplaySettingChanged, object: nil)
// A Siri shortcut can change the state of the app in the background. Hence, reload when opening the app.
NotificationCenter.default.addObserver(self, selector: #selector(actOnPossiblePasswordStoreUpdate), name: UIApplication.willEnterForegroundNotification, object: nil)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if shouldPopCurrentView {
let alert = UIAlertController(title: "Notice".localize(), message: "PreviousChangesDiscarded.".localize(), preferredStyle: UIAlertController.Style.alert)
alert.addAction(UIAlertAction.okAndPopView(controller: self))
present(alert, animated: true, completion: nil)
}
}
@objc
private func decryptThenShowPasswordSelector(_: Any) {
decryptThenShowPassword()
}
private func decryptThenShowPassword() {
if Defaults.isYubiKeyEnabled, YubiKitDeviceCapabilities.supportsISO7816NFCTags {
decryptThenShowPasswordYubiKey()
} else {
decryptThenShowPasswordLocalKey()
}
}
private func decryptThenShowPasswordLocalKey(keyID: String? = nil) {
guard let passwordEntity else {
Utils.alert(title: "CannotShowPassword".localize(), message: "PasswordDoesNotExist".localize(), controller: self, completion: {
self.navigationController!.popViewController(animated: true)
})
return
}
DispatchQueue.global(qos: .userInitiated).async {
// decrypt password
do {
let requestPGPKeyPassphrase = Utils.createRequestPGPKeyPassphraseHandler(controller: self)
self.password = try self.passwordStore.decrypt(passwordEntity: passwordEntity, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
self.showPassword()
} catch let AppError.pgpPrivateKeyNotFound(keyID: key) {
DispatchQueue.main.async {
// alert: cancel or try again
let alert = UIAlertController(title: "CannotShowPassword".localize(), message: AppError.pgpPrivateKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction.cancelAndPopView(controller: self))
let selectKey = UIAlertAction.selectKey(controller: self) { action in
self.decryptThenShowPasswordLocalKey(keyID: action.title)
}
alert.addAction(selectKey)
self.present(alert, animated: true, completion: nil)
}
} catch {
DispatchQueue.main.async {
// alert: cancel or try again
let alert = UIAlertController(title: "CannotShowPassword".localize(), message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction.cancelAndPopView(controller: self))
alert.addAction(
UIAlertAction(title: "TryAgain".localize(), style: .default) { _ in
self.decryptThenShowPasswordLocalKey()
}
)
self.present(alert, animated: true, completion: nil)
}
}
}
}
private func showPassword() {
DispatchQueue.main.async { [weak self] in
self?.setTableData()
self?.tableView.reloadData()
self?.editUIBarButtonItem.isEnabled = true
if !Defaults.isHidePasswordImagesOn {
if let urlString = self?.password?.urlString {
if self?.passwordEntity?.image == nil {
self?.updatePasswordImage(urlString: urlString)
}
}
}
}
}
private func setupOneTimePasswordAutoRefresh() {
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
// bail out of the timer code if the object has been freed
guard let strongSelf = self,
let otpType = strongSelf.password?.otpType,
otpType != .none,
let indexPath = strongSelf.oneTimePasswordIndexPath,
let cell = strongSelf.tableView.cellForRow(at: indexPath) as? LabelTableViewCell else {
return
}
switch otpType {
case .totp:
if let (title, otp) = strongSelf.password?.getOtpStrings() {
strongSelf.tableData[indexPath.section].item[indexPath.row] = title => otp
cell.cellData?.title = title
cell.cellData?.content = otp
}
default:
break
}
}
}
@objc
private func pressEdit(_: Any?) {
performSegue(withIdentifier: "editPasswordSegue", sender: self)
}
@objc
private func setShouldPopCurrentView() {
shouldPopCurrentView = true
}
@IBAction
private func cancelEditPassword(segue _: UIStoryboardSegue) {}
@IBAction
private func saveEditPassword(segue _: UIStoryboardSegue) {
if password!.changed != 0 {
saveEditPassword(password: password!)
}
}
private func saveEditPassword(password: Password, keyID: String? = nil) {
SVProgressHUD.show(withStatus: "Saving".localize())
do {
passwordEntity = try passwordStore.edit(passwordEntity: passwordEntity!, password: password, keyID: keyID)
setTableData()
tableView.reloadData()
SVProgressHUD.showSuccess(withStatus: "Success".localize())
SVProgressHUD.dismiss(withDelay: 1)
} catch let AppError.pgpPublicKeyNotFound(keyID: key) {
DispatchQueue.main.async {
// alert: cancel or select keys
SVProgressHUD.dismiss()
let alert = UIAlertController(title: "Cannot Edit Password", message: AppError.pgpPublicKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction.cancelAndPopView(controller: self))
let selectKey = UIAlertAction.selectKey(controller: self) { action in
self.saveEditPassword(password: password, keyID: action.title)
}
alert.addAction(selectKey)
self.present(alert, animated: true, completion: nil)
}
return
} catch {
DispatchQueue.main.async {
Utils.alert(title: "Error".localize(), message: error.localizedDescription, controller: self, completion: nil)
}
}
}
@IBAction
private func deletePassword(segue _: UIStoryboardSegue) {
do {
try passwordStore.delete(passwordEntity: passwordEntity!)
} catch {
Utils.alert(title: "Error".localize(), message: error.localizedDescription, controller: self, completion: nil)
}
_ = navigationController?.popViewController(animated: true)
}
private func setTableData() {
tableData = [TableSection]()
// name section
var section = TableSection(type: .name)
section.item.append(AdditionField())
tableData.append(section)
// main section
section = TableSection(type: .main)
let password = password!
if let username = password.username {
section.item.append(Constants.USERNAME_KEYWORD => username)
}
if let user = password.user {
section.item.append(Constants.USER_KEYWORD => user)
}
if let login = password.login {
section.item.append(Constants.LOGIN_KEYWORD => login)
}
section.item.append(Constants.PASSWORD_KEYWORD => password.password)
tableData.append(section)
// addition section
// show one time password
if password.otpType != .none {
if let (title, otp) = self.password?.getOtpStrings() {
section = TableSection(type: .addition, header: "OneTimePassword".localize())
section.item.append(title => otp)
tableData.append(section)
oneTimePasswordIndexPath = IndexPath(row: 0, section: tableData.count - 1)
}
}
// show additional information
let filteredAdditionKeys = password.getFilteredAdditions()
if !filteredAdditionKeys.isEmpty {
section = TableSection(type: .addition, header: "Additions".localize())
section.item.append(contentsOf: filteredAdditionKeys)
tableData.append(section)
}
// misc section
section = TableSection(type: .misc)
section.item.append(AdditionField(title: "ShowRaw".localize()))
tableData.append(section)
}
override func prepare(for segue: UIStoryboardSegue, sender _: Any?) {
if segue.identifier == "editPasswordSegue" {
if let controller = segue.destination as? UINavigationController {
if let editController = controller.viewControllers.first as? EditPasswordTableViewController {
editController.password = password
}
}
} else if segue.identifier == "showRawPasswordSegue" {
if let controller = segue.destination as? UINavigationController {
if let controller = controller.viewControllers.first as? RawPasswordViewController {
controller.password = password
}
}
}
}
private func updatePasswordImage(urlString: String) {
var newURLString = urlString
if urlString.lowercased().hasPrefix("http://") {
// try to replace http url to https url
newURLString = urlString.replacingOccurrences(
of: "http://",
with: "https://",
options: .caseInsensitive,
range: urlString.range(of: "http://")
)
} else if urlString.lowercased().hasPrefix("https://") {
// do nothing here
} else {
// if a url does not start with http or https, try to add https
newURLString = "https://\(urlString)"
}
try? FavIcon.downloadPreferred(newURLString) { [weak self] result in
if case let .success(image) = result {
let indexPath = IndexPath(row: 0, section: 0)
self?.passwordImage = image
self?.tableView.reloadRows(at: [indexPath], with: UITableView.RowAnimation.automatic)
let imageData = image.jpegData(compressionQuality: 1)
if let entity = self?.passwordEntity {
entity.image = imageData
}
}
}
}
@objc
private func tapMenu(recognizer: UITapGestureRecognizer) {
if recognizer.state == UIGestureRecognizer.State.ended {
let tapLocation = recognizer.location(in: tableView)
if let tapIndexPath = tableView.indexPathForRow(at: tapLocation) {
if let tappedCell = tableView.cellForRow(at: tapIndexPath) as? LabelTableViewCell {
tappedCell.becomeFirstResponder()
let menuController = UIMenuController.shared
let revealItem = UIMenuItem(title: "Reveal".localize(), action: #selector(LabelTableViewCell.revealPassword))
let concealItem = UIMenuItem(title: "Conceal".localize(), action: #selector(LabelTableViewCell.concealPassword))
let nextHOTPItem = UIMenuItem(title: "NextPassword".localize(), action: #selector(LabelTableViewCell.getNextHOTP))
let openURLItem = UIMenuItem(title: "CopyAndOpen".localize(), action: #selector(LabelTableViewCell.openLink))
menuController.menuItems = [revealItem, concealItem, nextHOTPItem, openURLItem]
menuController.showMenu(from: tappedCell.contentLabel.superview!, rect: tappedCell.contentLabel.frame)
}
}
}
}
func gestureRecognizer(_: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
if touch.view!.isKind(of: UIButton.classForCoder()) {
return false
}
return true
}
@IBAction
private func back(segue _: UIStoryboardSegue) {}
func getNextHOTP() {
guard password != nil, passwordEntity != nil, password?.otpType == .hotp else {
DispatchQueue.main.async {
Utils.alert(title: "Error".localize(), message: "GetNextPasswordOfNonHotp.".localize(), controller: self, completion: nil)
}
return
}
// copy HOTP to pasteboard (will update counter)
if let plainPassword = password!.getNextHotp() {
SecurePasteboard.shared.copy(textToCopy: plainPassword)
}
// commit the change of HOTP counter
if password!.changed != 0 {
do {
passwordEntity = try passwordStore.edit(passwordEntity: passwordEntity!, password: password!)
SVProgressHUD.showSuccess(withStatus: "PasswordCopied".localize() | "CounterUpdated".localize())
SVProgressHUD.dismiss(withDelay: 1)
} catch {
SVProgressHUD.showSuccess(withStatus: error.localizedDescription)
SVProgressHUD.dismiss(withDelay: 1)
}
}
}
func openLink(to address: String?) {
guard address != nil, let url = URL(string: formActualWebAddress(from: address!)) else {
DispatchQueue.main.async {
Utils.alert(title: "Error".localize(), message: "CannotFindValidUrl".localize(), controller: self, completion: nil)
}
return
}
SecurePasteboard.shared.copy(textToCopy: password?.password)
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
private func formActualWebAddress(from: String) -> String {
let lowercased = from.lowercased()
if !(lowercased.starts(with: "https://") || lowercased.starts(with: "http://")) {
return "https://\(from)"
}
return from
}
override func numberOfSections(in _: UITableView) -> Int {
tableData.count
}
override func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int {
tableData[section].item.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let sectionIndex = indexPath.section
let rowIndex = indexPath.row
let tableDataItem = tableData[sectionIndex].item[rowIndex]
switch tableData[sectionIndex].type {
case .name:
let cell = tableView.dequeueReusableCell(withIdentifier: "passwordDetailTitleTableViewCell", for: indexPath) as! PasswordDetailTitleTableViewCell
if !Defaults.isHidePasswordImagesOn {
cell.labelCellConstraint.isActive = false
cell.labelImageConstraint.isActive = true
cell.passwordImageImageView.image = passwordImage ?? #imageLiteral(resourceName: "PasswordImagePlaceHolder")
cell.passwordImageImageView.isHidden = false
} else {
cell.passwordImageImageView.image = nil
cell.passwordImageImageView.isHidden = true
cell.labelImageConstraint.isActive = false
cell.labelCellConstraint.isActive = true
}
let passwordName = passwordEntity!.name
if passwordEntity!.isSynced == false {
cell.nameLabel.text = "\(passwordName) ↻"
} else {
cell.nameLabel.text = passwordName
}
cell.categoryLabel.text = passwordEntity!.dirText
cell.selectionStyle = .none
return cell
case .addition, .main:
let cell = tableView.dequeueReusableCell(withIdentifier: "labelCell", for: indexPath) as! LabelTableViewCell
let titleData = tableDataItem.title
let contentData = tableDataItem.content
cell.delegatePasswordTableView = self
cell.cellData = LabelTableViewCellData(title: titleData, content: contentData)
cell.selectionStyle = .none
return cell
case .misc:
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
cell.textLabel?.text = tableDataItem.title
cell.selectionStyle = .default
addHiddenFieldInformation(to: cell)
return cell
}
}
private func addHiddenFieldInformation(to cell: UITableViewCell) {
guard password != nil, let detailTextLabel = cell.detailTextLabel else {
return
}
var numberOfHiddenFields = 0
numberOfHiddenFields += Defaults.isHideUnknownOn ? password!.numberOfUnknowns : 0
numberOfHiddenFields += Defaults.isHideOTPOn ? password!.numberOfOtpRelated : 0
guard numberOfHiddenFields > 0 else {
return
}
detailTextLabel.textAlignment = .center
detailTextLabel.textColor = .gray
detailTextLabel.text = "HiddenFields(%d)".localize(numberOfHiddenFields)
}
override func tableView(_: UITableView, titleForHeaderInSection section: Int) -> String? {
tableData[section].header
}
override func tableView(_: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
if tableData[section].header != nil {
return 30
}
return UITableView.automaticDimension
}
override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
if section == tableData.count - 1 {
let view = UIView()
let footerLabel = UILabel(frame: CGRect(x: 15, y: 15, width: tableView.frame.width, height: 60))
footerLabel.numberOfLines = 0
footerLabel.font = UIFont.preferredFont(forTextStyle: .footnote)
footerLabel.textColor = UIColor.gray
let dateString = passwordStore.getLatestUpdateInfo(path: password!.path)
footerLabel.text = "LastUpdated".localize(dateString)
view.addSubview(footerLabel)
return view
}
return nil
}
override func tableView(_: UITableView, performAction action: Selector, forRowAt indexPath: IndexPath, withSender _: Any?) {
if action == #selector(copy(_:)) {
SecurePasteboard.shared.copy(textToCopy: tableData[indexPath.section].item[indexPath.row].content)
}
}
override func tableView(_: UITableView, canPerformAction action: Selector, forRowAt indexPath: IndexPath, withSender _: Any?) -> Bool {
let section = tableData[indexPath.section]
switch section.type {
case .addition, .main:
return action == #selector(UIResponderStandardEditActions.copy)
default:
return false
}
}
override func tableView(_: UITableView, shouldShowMenuForRowAt _: IndexPath) -> Bool {
true
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let section = tableData[indexPath.section]
if section.type == .misc {
if section.item[indexPath.row].title == "ShowRaw".localize() {
performSegue(withIdentifier: "showRawPasswordSegue", sender: self)
}
}
tableView.deselectRow(at: indexPath, animated: true)
}
}
extension PasswordDetailTableViewController {
@objc
func actOnPossiblePasswordStoreUpdate() {
DispatchQueue.main.async {
if let path = self.passwordPath {
// reload PasswordEntity because all PasswordEntities are re-created on PasswordStore update
self.passwordEntity = PasswordStore.shared.fetchPasswordEntity(with: path)
// dismiss if the PasswordEntity does not exist anymore
if self.passwordEntity == nil {
self.navigationController?.popToRootViewController(animated: true)
} else {
self.decryptThenShowPassword()
}
}
}
}
private func requestYubiKeyPIN(completion: @escaping (String) -> Void) {
let alert = UIAlertController(title: "YubiKey PIN", message: "Verify YubiKey OpenPGP PIN.", preferredStyle: .alert)
alert.addAction(
UIAlertAction.cancel { _ in
self.navigationController!.popViewController(animated: true)
}
)
alert.addAction(
UIAlertAction.ok { _ in
let pin = alert.textFields?.first?.text ?? ""
completion(pin)
}
)
alert.addTextField { textField in
textField.isSecureTextEntry = true
}
present(alert, animated: true)
}
private func handleError(error: AppError) {
DispatchQueue.main.async {
self.presentFailureAlert(message: error.localizedDescription) { _ in
self.navigationController?.popViewController(animated: true)
}
}
}
private func decryptThenShowPasswordYubiKey() {
guard let passwordEntity else {
handleError(error: AppError.other(message: "PasswordDoesNotExist"))
return
}
let encryptedDataPath = passwordEntity.fileURL(in: PasswordStore.shared.storeURL)
guard let encryptedData = try? Data(contentsOf: encryptedDataPath) else {
handleError(error: AppError.other(message: "PasswordDoesNotExist"))
return
}
requestYubiKeyPIN { [self] pin in
Task {
do {
let decryptedData = try await passwordYubiKeyDecryptor.yubiKeyDecrypt(encryptedData: encryptedData, pin: pin)
guard let decryptedDataString = String(data: decryptedData, encoding: .utf8) else {
throw AppError.yubiKey(.decipher(message: "Failed to convert plaintext to string."))
}
password = Password(name: passwordEntity.name, path: passwordEntity.path, plainText: decryptedDataString)
self.password = password
self.showPassword()
} catch let error as AppError {
handleError(error: error)
} catch {
handleError(error: AppError.yubiKey(.other(message: error.localizedDescription)))
}
}
}
}
}