Show TOTP codes in password details (#75)
This commit is contained in:
@@ -2150,15 +2150,111 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class TotpDetailViewController: UITableViewController {
|
||||
private let authentication: MobileAuthentication
|
||||
private var detail: MobileTotpDetail
|
||||
private let codeContainer = UIView()
|
||||
private final class TotpCodeView: UIView {
|
||||
private let issuerLabel = UILabel()
|
||||
private let accountLabel = UILabel()
|
||||
private let codeLabel = UILabel()
|
||||
private let countdownLabel = UILabel()
|
||||
private let progress = UIProgressView(progressViewStyle: .default)
|
||||
var copyRequested: (() -> Void)?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
issuerLabel.font = .preferredFont(forTextStyle: .title2)
|
||||
issuerLabel.adjustsFontForContentSizeCategory = true
|
||||
issuerLabel.textAlignment = .center
|
||||
accountLabel.font = .preferredFont(forTextStyle: .body)
|
||||
accountLabel.textColor = .secondaryLabel
|
||||
accountLabel.adjustsFontForContentSizeCategory = true
|
||||
accountLabel.textAlignment = .center
|
||||
accountLabel.numberOfLines = 0
|
||||
codeLabel.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont(
|
||||
for: .monospacedDigitSystemFont(ofSize: 48, weight: .semibold)
|
||||
)
|
||||
codeLabel.adjustsFontForContentSizeCategory = true
|
||||
codeLabel.textAlignment = .center
|
||||
codeLabel.minimumScaleFactor = 0.55
|
||||
codeLabel.adjustsFontSizeToFitWidth = true
|
||||
codeLabel.layer.cornerRadius = 12
|
||||
codeLabel.layer.masksToBounds = true
|
||||
codeLabel.isAccessibilityElement = true
|
||||
codeLabel.isUserInteractionEnabled = true
|
||||
codeLabel.accessibilityTraits.insert(.button)
|
||||
codeLabel.accessibilityHint = "Copies the current code"
|
||||
countdownLabel.font = .preferredFont(forTextStyle: .footnote)
|
||||
countdownLabel.textColor = .secondaryLabel
|
||||
countdownLabel.adjustsFontForContentSizeCategory = true
|
||||
countdownLabel.textAlignment = .center
|
||||
|
||||
let stack = UIStackView(
|
||||
arrangedSubviews: [issuerLabel, accountLabel, codeLabel, progress, countdownLabel]
|
||||
)
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 12
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
|
||||
stack.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
|
||||
stack.topAnchor.constraint(equalTo: topAnchor, constant: 20),
|
||||
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20),
|
||||
])
|
||||
codeLabel.addGestureRecognizer(
|
||||
UITapGestureRecognizer(target: self, action: #selector(copyCode))
|
||||
)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
func apply(_ detail: MobileTotpDetail) {
|
||||
issuerLabel.text = detail.issuer ?? "TOTP"
|
||||
accountLabel.text = detail.account
|
||||
codeLabel.text = groupedCode(detail.code)
|
||||
codeLabel.accessibilityLabel =
|
||||
"Current code \(detail.code.map(String.init).joined(separator: " "))"
|
||||
updateCountdown(detail)
|
||||
}
|
||||
|
||||
func updateCountdown(_ detail: MobileTotpDetail) {
|
||||
let remaining = detail.validUntil.saturatingSubtracting(currentUnixSeconds())
|
||||
let fraction = detail.period == 0 ? 0 : Float(remaining) / Float(detail.period)
|
||||
progress.setProgress(min(max(fraction, 0), 1), animated: true)
|
||||
countdownLabel.text = "\(remaining) seconds remaining"
|
||||
progress.accessibilityLabel = "Code validity"
|
||||
progress.accessibilityValue = countdownLabel.text
|
||||
}
|
||||
|
||||
func clear() {
|
||||
issuerLabel.text = nil
|
||||
accountLabel.text = nil
|
||||
codeLabel.text = nil
|
||||
countdownLabel.text = nil
|
||||
progress.progress = 0
|
||||
}
|
||||
|
||||
func flashCopied() {
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
UIAccessibility.post(notification: .announcement, argument: "TOTP code copied")
|
||||
UIView.animate(withDuration: 0.12, animations: {
|
||||
self.codeLabel.backgroundColor = .systemGreen.withAlphaComponent(0.28)
|
||||
}) { _ in
|
||||
UIView.animate(withDuration: 0.55) { self.codeLabel.backgroundColor = .clear }
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func copyCode() {
|
||||
copyRequested?()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class TotpDetailViewController: UITableViewController {
|
||||
private let authentication: MobileAuthentication
|
||||
private var detail: MobileTotpDetail
|
||||
private let codeView = TotpCodeView()
|
||||
private let watchSwitch = UISwitch()
|
||||
private var timerTask: Task<Void, Never>?
|
||||
private var loadTask: Task<Void, Never>?
|
||||
@@ -2354,56 +2450,15 @@ private final class TotpDetailViewController: UITableViewController {
|
||||
}
|
||||
|
||||
private func configureHeader() {
|
||||
issuerLabel.font = .preferredFont(forTextStyle: .title2)
|
||||
issuerLabel.adjustsFontForContentSizeCategory = true
|
||||
issuerLabel.textAlignment = .center
|
||||
accountLabel.font = .preferredFont(forTextStyle: .body)
|
||||
accountLabel.textColor = .secondaryLabel
|
||||
accountLabel.adjustsFontForContentSizeCategory = true
|
||||
accountLabel.textAlignment = .center
|
||||
accountLabel.numberOfLines = 0
|
||||
codeLabel.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont(
|
||||
for: .monospacedDigitSystemFont(ofSize: 48, weight: .semibold)
|
||||
)
|
||||
codeLabel.adjustsFontForContentSizeCategory = true
|
||||
codeLabel.textAlignment = .center
|
||||
codeLabel.minimumScaleFactor = 0.55
|
||||
codeLabel.adjustsFontSizeToFitWidth = true
|
||||
codeLabel.layer.cornerRadius = 12
|
||||
codeLabel.layer.masksToBounds = true
|
||||
codeLabel.isAccessibilityElement = true
|
||||
codeLabel.isUserInteractionEnabled = true
|
||||
codeLabel.accessibilityTraits.insert(.button)
|
||||
codeLabel.accessibilityHint = "Copies the current code"
|
||||
countdownLabel.font = .preferredFont(forTextStyle: .footnote)
|
||||
countdownLabel.textColor = .secondaryLabel
|
||||
countdownLabel.adjustsFontForContentSizeCategory = true
|
||||
countdownLabel.textAlignment = .center
|
||||
let stack = UIStackView(arrangedSubviews: [issuerLabel, accountLabel, codeLabel, progress, countdownLabel])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 12
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
codeContainer.addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.leadingAnchor.constraint(equalTo: codeContainer.layoutMarginsGuide.leadingAnchor),
|
||||
stack.trailingAnchor.constraint(equalTo: codeContainer.layoutMarginsGuide.trailingAnchor),
|
||||
stack.topAnchor.constraint(equalTo: codeContainer.topAnchor, constant: 20),
|
||||
stack.bottomAnchor.constraint(equalTo: codeContainer.bottomAnchor, constant: -20),
|
||||
])
|
||||
codeContainer.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 1)
|
||||
tableView.tableHeaderView = codeContainer
|
||||
codeLabel.addGestureRecognizer(
|
||||
UITapGestureRecognizer(target: self, action: #selector(copyRequested))
|
||||
)
|
||||
codeView.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 1)
|
||||
codeView.copyRequested = { [weak self] in self?.copyRequested() }
|
||||
tableView.tableHeaderView = codeView
|
||||
}
|
||||
|
||||
private func apply(_ detail: MobileTotpDetail) {
|
||||
self.detail = detail
|
||||
title = detail.issuer ?? detail.account
|
||||
issuerLabel.text = detail.issuer ?? "TOTP"
|
||||
accountLabel.text = detail.account
|
||||
codeLabel.text = groupedCode(detail.code)
|
||||
codeLabel.accessibilityLabel = "Current code \(detail.code.map(String.init).joined(separator: " "))"
|
||||
codeView.apply(detail)
|
||||
watchSwitch.setOn(detail.sharedWithWatch, animated: false)
|
||||
tableView.reloadData()
|
||||
updateCountdown()
|
||||
@@ -2429,12 +2484,7 @@ private final class TotpDetailViewController: UITableViewController {
|
||||
}
|
||||
|
||||
private func updateCountdown() {
|
||||
let remaining = detail.validUntil.saturatingSubtracting(currentUnixSeconds())
|
||||
let fraction = detail.period == 0 ? 0 : Float(remaining) / Float(detail.period)
|
||||
progress.setProgress(min(max(fraction, 0), 1), animated: true)
|
||||
countdownLabel.text = "\(remaining) seconds remaining"
|
||||
progress.accessibilityLabel = "Code validity"
|
||||
progress.accessibilityValue = countdownLabel.text
|
||||
codeView.updateCountdown(detail)
|
||||
}
|
||||
|
||||
private func refreshCode() {
|
||||
@@ -2467,13 +2517,7 @@ private final class TotpDetailViewController: UITableViewController {
|
||||
}
|
||||
|
||||
private func flashCopied() {
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
UIAccessibility.post(notification: .announcement, argument: "TOTP code copied")
|
||||
UIView.animate(withDuration: 0.12, animations: {
|
||||
self.codeLabel.backgroundColor = .systemGreen.withAlphaComponent(0.28)
|
||||
}) { _ in
|
||||
UIView.animate(withDuration: 0.55) { self.codeLabel.backgroundColor = .clear }
|
||||
}
|
||||
codeView.flashCopied()
|
||||
}
|
||||
|
||||
private func handle(_ failure: AuthenticationFailure) {
|
||||
@@ -2489,7 +2533,7 @@ private final class TotpDetailViewController: UITableViewController {
|
||||
} else {
|
||||
timerTask?.cancel()
|
||||
detail.code = ""
|
||||
codeLabel.text = nil
|
||||
codeView.clear()
|
||||
navigationItem.rightBarButtonItem = nil
|
||||
watchSwitch.isEnabled = false
|
||||
var configuration = UIContentUnavailableConfiguration.empty()
|
||||
@@ -2511,7 +2555,7 @@ private final class TotpDetailViewController: UITableViewController {
|
||||
loadTask?.cancel()
|
||||
clipboardTask?.cancel()
|
||||
detail.code = ""
|
||||
codeLabel.text = nil
|
||||
codeView.clear()
|
||||
if UIPasteboard.general.string == copiedValue {
|
||||
UIPasteboard.general.items = []
|
||||
}
|
||||
@@ -3646,12 +3690,18 @@ private struct PasswordFailure: Error, Sendable {
|
||||
private final class LockedPasswordViewController: UITableViewController {
|
||||
private let entry: MobilePasswordRow
|
||||
private let authentication: MobileAuthentication?
|
||||
private let totpCodeView = TotpCodeView()
|
||||
private var unlockTask: Task<Void, Never>?
|
||||
private var entryTask: Task<Void, Never>?
|
||||
private var totpTask: Task<Void, Never>?
|
||||
private var totpTimerTask: Task<Void, Never>?
|
||||
private var clipboardTask: Task<Void, Never>?
|
||||
private var feedbackTask: Task<Void, Never>?
|
||||
private var state: MobileAuthenticationState?
|
||||
private var page: MobileEntryPage?
|
||||
private var totp: MobileTotpDetail?
|
||||
private var cacheNotice: String?
|
||||
private var copiedTotpValue: String?
|
||||
private var revealedValues: [UInt64: String] = [:]
|
||||
|
||||
init(entry: MobilePasswordRow, authentication: MobileAuthentication?) {
|
||||
@@ -3685,6 +3735,8 @@ private final class LockedPasswordViewController: UITableViewController {
|
||||
deinit {
|
||||
unlockTask?.cancel()
|
||||
entryTask?.cancel()
|
||||
totpTask?.cancel()
|
||||
totpTimerTask?.cancel()
|
||||
clipboardTask?.cancel()
|
||||
feedbackTask?.cancel()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
@@ -3695,6 +3747,19 @@ private final class LockedPasswordViewController: UITableViewController {
|
||||
refreshState()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
guard let header = tableView.tableHeaderView else { return }
|
||||
let height = header.systemLayoutSizeFitting(
|
||||
CGSize(width: tableView.bounds.width, height: 0),
|
||||
withHorizontalFittingPriority: .required,
|
||||
verticalFittingPriority: .fittingSizeLevel
|
||||
).height
|
||||
guard abs(header.frame.height - height) > 0.5 else { return }
|
||||
header.frame.size.height = height
|
||||
tableView.tableHeaderView = header
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
page?.sections.count ?? 0
|
||||
}
|
||||
@@ -3713,6 +3778,14 @@ private final class LockedPasswordViewController: UITableViewController {
|
||||
page?.sections[section].title
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
titleForFooterInSection section: Int
|
||||
) -> String? {
|
||||
guard let page, section == page.sections.indices.last else { return nil }
|
||||
return cacheNotice
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
@@ -3853,8 +3926,11 @@ private final class LockedPasswordViewController: UITableViewController {
|
||||
entryTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
return Result<MobileEntryPage, AuthenticationFailure>.success(
|
||||
try authentication.entryPage(path: path)
|
||||
return Result<MobileEntryPresentation, AuthenticationFailure>.success(
|
||||
try authentication.entryPage(
|
||||
path: path,
|
||||
unixSeconds: currentUnixSeconds()
|
||||
)
|
||||
)
|
||||
} catch let error as MobileAuthenticationFfiError {
|
||||
return .failure(AuthenticationFailure(error))
|
||||
@@ -3864,11 +3940,14 @@ private final class LockedPasswordViewController: UITableViewController {
|
||||
}.value
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
switch result {
|
||||
case let .success(page):
|
||||
self.page = page
|
||||
title = page.title
|
||||
case let .success(presentation):
|
||||
page = presentation.page
|
||||
totp = presentation.totp
|
||||
cacheNotice = presentation.cacheNotice
|
||||
title = presentation.page.title
|
||||
contentUnavailableConfiguration = nil
|
||||
installLockButton()
|
||||
configureTotpHeader()
|
||||
tableView.reloadData()
|
||||
case let .failure(failure):
|
||||
handle(failure)
|
||||
@@ -3923,6 +4002,115 @@ private final class LockedPasswordViewController: UITableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
private func configureTotpHeader() {
|
||||
guard let totp else {
|
||||
totpTimerTask?.cancel()
|
||||
totpCodeView.clear()
|
||||
tableView.tableHeaderView = nil
|
||||
return
|
||||
}
|
||||
totpCodeView.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 1)
|
||||
totpCodeView.copyRequested = { [weak self] in self?.copyTotpCode() }
|
||||
totpCodeView.apply(totp)
|
||||
tableView.tableHeaderView = totpCodeView
|
||||
startTotpTimer()
|
||||
}
|
||||
|
||||
private func startTotpTimer() {
|
||||
totpTimerTask?.cancel()
|
||||
totpTimerTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
do {
|
||||
try await Task.sleep(for: .seconds(1))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
guard let self, let totp else { return }
|
||||
if currentUnixSeconds() >= totp.validUntil {
|
||||
refreshTotpCode()
|
||||
} else {
|
||||
totpCodeView.updateCountdown(totp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshTotpCode() {
|
||||
guard totpTask == nil, let authentication else { return }
|
||||
let path = entry.path
|
||||
totpTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
return Result<MobileTotpDetail, AuthenticationFailure>.success(
|
||||
try authentication.totpDetail(
|
||||
path: path,
|
||||
unixSeconds: currentUnixSeconds()
|
||||
)
|
||||
)
|
||||
} catch let error as MobileAuthenticationFfiError {
|
||||
return .failure(AuthenticationFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
guard let self else { return }
|
||||
totpTask = nil
|
||||
guard !Task.isCancelled else { return }
|
||||
switch result {
|
||||
case let .success(detail):
|
||||
totp = detail
|
||||
totpCodeView.apply(detail)
|
||||
case let .failure(failure):
|
||||
handle(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func copyTotpCode() {
|
||||
guard totpTask == nil, let authentication else { return }
|
||||
let path = entry.path
|
||||
totpTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
try authentication.touchUserActivity()
|
||||
return Result<MobileEntryCopy, AuthenticationFailure>.success(
|
||||
try authentication.copyTotpCode(
|
||||
path: path,
|
||||
unixSeconds: currentUnixSeconds()
|
||||
)
|
||||
)
|
||||
} catch let error as MobileAuthenticationFfiError {
|
||||
return .failure(AuthenticationFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
guard let self else { return }
|
||||
totpTask = nil
|
||||
guard !Task.isCancelled else { return }
|
||||
switch result {
|
||||
case let .success(copy):
|
||||
UIPasteboard.general.string = copy.value
|
||||
copiedTotpValue = copy.value
|
||||
totpCodeView.flashCopied()
|
||||
clipboardTask?.cancel()
|
||||
clipboardTask = Task { @MainActor in
|
||||
do {
|
||||
try await Task.sleep(for: .seconds(copy.timeoutSeconds))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if UIPasteboard.general.string == copy.value {
|
||||
UIPasteboard.general.items = []
|
||||
}
|
||||
self.copiedTotpValue = nil
|
||||
}
|
||||
case let .failure(failure):
|
||||
handle(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func edit(_ field: MobileEntryField) {
|
||||
guard let authentication else { return }
|
||||
entryTask?.cancel()
|
||||
@@ -4010,8 +4198,18 @@ private final class LockedPasswordViewController: UITableViewController {
|
||||
|
||||
private func maskAndDiscardEntry() {
|
||||
entryTask?.cancel()
|
||||
totpTask?.cancel()
|
||||
totpTimerTask?.cancel()
|
||||
feedbackTask?.cancel()
|
||||
page = nil
|
||||
totp = nil
|
||||
cacheNotice = nil
|
||||
totpCodeView.clear()
|
||||
tableView.tableHeaderView = nil
|
||||
if UIPasteboard.general.string == copiedTotpValue {
|
||||
UIPasteboard.general.items = []
|
||||
}
|
||||
copiedTotpValue = nil
|
||||
revealedValues.removeAll(keepingCapacity: false)
|
||||
tableView.reloadData()
|
||||
navigationItem.rightBarButtonItem = nil
|
||||
|
||||
Reference in New Issue
Block a user