Implement GPG key QR transfer

This commit is contained in:
2026-08-11 21:25:32 +02:00
parent b01cc8bb6d
commit 358ba7d46d
9 changed files with 2653 additions and 5 deletions

View File

@@ -1,4 +1,7 @@
import UIKit
import AVFoundation
import Vision
import VisionKit
extension Notification.Name {
static let ironStorageLocalStoreDidChange = Notification.Name(
@@ -10,6 +13,9 @@ extension Notification.Name {
static let ironStorageWatchSnapshotDidChange = Notification.Name(
"de.rfc1437.ironstorage.watch-snapshot-did-change"
)
static let ironStorageKeyMaterialDidChange = Notification.Name(
"de.rfc1437.ironstorage.key-material-did-change"
)
}
@main
@@ -17,6 +23,20 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
private var context: AppContext?
override init() {
super.init()
NotificationCenter.default.addObserver(
self,
selector: #selector(keyMaterialDidChange),
name: .ironStorageKeyMaterialDidChange,
object: nil
)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
@@ -33,6 +53,14 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
func applicationDidEnterBackground(_ application: UIApplication) {
context?.lockForBackground()
}
@objc private func keyMaterialDidChange() {
guard let window else { return }
context?.lockForBackground()
let context = AppContext()
self.context = context
window.rootViewController = context.makeRootController()
}
}
@MainActor
@@ -676,6 +704,7 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
fileprivate let shellTab = MobileTab.preferences
private var page: MobilePage
private let authentication: MobileAuthentication?
private let keyTransfer = try? mobileKeyTransfer()
private var state: MobileAuthenticationState?
private var preferenceTask: Task<Void, Never>?
private var loadTask: Task<Void, Never>?
@@ -718,21 +747,25 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
}
override func numberOfSections(in tableView: UITableView) -> Int {
page.state == .ready ? 2 : 0
page.state == .ready ? 3 : 0
}
override func tableView(
_ tableView: UITableView,
numberOfRowsInSection section: Int
) -> Int {
section == 0 ? 1 : 2
section == 0 ? 2 : (section == 1 ? 1 : 2)
}
override func tableView(
_ tableView: UITableView,
titleForHeaderInSection section: Int
) -> String? {
section == 0 ? "Secure Unlock" : "Authentication Session"
switch section {
case 0: "GPG Key Transfer"
case 1: "Secure Unlock"
default: "Authentication Session"
}
}
override func tableView(
@@ -740,6 +773,9 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
titleForFooterInSection section: Int
) -> String? {
if section == 0 {
return "Scan or display ASCII-armored GPG keys. Private-key transfers require explicit confirmation and passphrase validation."
}
if section == 1 {
return "When enabled, the GPG passphrase is device-only, requires a device passcode, and is invalidated when enrolled biometrics change."
}
return "Manual lock and inactivity expiry immediately revoke the shared Rust authentication lease."
@@ -752,6 +788,16 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
var content = cell.defaultContentConfiguration()
if indexPath.section == 0 {
let importing = indexPath.row == 0
content.image = UIImage(systemName: importing ? "qrcode.viewfinder" : "qrcode")
content.text = importing ? "Import GPG Key" : "Export GPG Key"
content.secondaryText = importing
? "Scan one or more transfer QR codes"
: "Display public or private key armor"
cell.accessoryType = .disclosureIndicator
cell.isUserInteractionEnabled = keyTransfer != nil
cell.contentView.alpha = keyTransfer == nil ? 0.45 : 1
} else if indexPath.section == 1 {
content.image = UIImage(systemName: "faceid")
content.text = "Biometric Unlock"
content.secondaryText = state?.biometricUnlockEnabled == true
@@ -788,7 +834,19 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard indexPath.section == 1, indexPath.row == 1, let authentication else { return }
if indexPath.section == 0 {
guard let keyTransfer else { return }
if indexPath.row == 0 {
requestKeyScanner(keyTransfer)
} else {
navigationController?.pushViewController(
KeyExportListViewController(transfer: keyTransfer),
animated: true
)
}
return
}
guard indexPath.section == 2, indexPath.row == 1, let authentication else { return }
do {
try authentication.manualLock()
refreshState()
@@ -845,6 +903,67 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
navigationController?.pushViewController(TokenUpdateViewController(), animated: true)
}
private func requestKeyScanner(_ transfer: MobileKeyTransfer) {
guard DataScannerViewController.isSupported else {
presentMessage(
title: "Key Scanning Is Unavailable",
detail: "This device does not support live QR scanning."
)
return
}
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
showKeyScanner(transfer)
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
Task { @MainActor in
guard let self else { return }
if granted {
self.showKeyScanner(transfer)
} else {
self.showCameraDenied()
}
}
}
default:
showCameraDenied()
}
}
private func showKeyScanner(_ transfer: MobileKeyTransfer) {
guard DataScannerViewController.isAvailable else {
presentMessage(
title: "Camera Is Unavailable",
detail: "Close other camera apps and try again."
)
return
}
navigationController?.pushViewController(
KeyImportScannerViewController(transfer: transfer),
animated: true
)
}
private func showCameraDenied() {
let alert = UIAlertController(
title: "Camera Access Is Off",
message: "Allow camera access in Settings to scan GPG key QR codes.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Open Settings", style: .default) { _ in
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
})
present(alert, animated: true)
}
private func presentMessage(title: String, detail: String) {
let alert = UIAlertController(title: title, message: detail, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
@objc private func authenticationDidChange() {
refreshState()
}
@@ -893,6 +1012,569 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
}
}
@MainActor
private final class KeyImportScannerViewController: UIViewController,
DataScannerViewControllerDelegate
{
private let importer: MobileKeyTransferImport
private let scanner = DataScannerViewController(
recognizedDataTypes: [.barcode(symbologies: [.qr])],
qualityLevel: .balanced,
recognizesMultipleItems: true,
isHighFrameRateTrackingEnabled: false,
isPinchToZoomEnabled: true,
isGuidanceEnabled: true,
isHighlightingEnabled: true
)
private let progressLabel = UILabel()
private let shield = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial))
private var completing = false
init(transfer: MobileKeyTransfer) {
importer = transfer.importer()
super.init(nibName: nil, bundle: nil)
title = "Import GPG Key"
navigationItem.largeTitleDisplayMode = .never
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
deinit {
NotificationCenter.default.removeObserver(self)
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
scanner.delegate = self
addChild(scanner)
scanner.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(scanner.view)
scanner.didMove(toParent: self)
let material = UIBlurEffect(style: .systemMaterial)
let status = UIVisualEffectView(effect: material)
status.layer.cornerRadius = 12
status.clipsToBounds = true
status.translatesAutoresizingMaskIntoConstraints = false
progressLabel.text = "Point the camera at a key-transfer QR code"
progressLabel.font = .preferredFont(forTextStyle: .callout)
progressLabel.adjustsFontForContentSizeCategory = true
progressLabel.numberOfLines = 0
progressLabel.textAlignment = .center
progressLabel.translatesAutoresizingMaskIntoConstraints = false
status.contentView.addSubview(progressLabel)
view.addSubview(status)
let shieldLabel = UILabel()
shieldLabel.text = "Camera hidden while IronStorage is inactive or the screen is captured"
shieldLabel.font = .preferredFont(forTextStyle: .headline)
shieldLabel.adjustsFontForContentSizeCategory = true
shieldLabel.numberOfLines = 0
shieldLabel.textAlignment = .center
shieldLabel.translatesAutoresizingMaskIntoConstraints = false
shield.contentView.addSubview(shieldLabel)
shield.translatesAutoresizingMaskIntoConstraints = false
shield.isHidden = true
shield.accessibilityViewIsModal = true
view.addSubview(shield)
NSLayoutConstraint.activate([
scanner.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scanner.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scanner.view.topAnchor.constraint(equalTo: view.topAnchor),
scanner.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
status.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 20),
status.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -20),
status.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20),
progressLabel.leadingAnchor.constraint(equalTo: status.contentView.leadingAnchor, constant: 16),
progressLabel.trailingAnchor.constraint(equalTo: status.contentView.trailingAnchor, constant: -16),
progressLabel.topAnchor.constraint(equalTo: status.contentView.topAnchor, constant: 12),
progressLabel.bottomAnchor.constraint(equalTo: status.contentView.bottomAnchor, constant: -12),
shield.leadingAnchor.constraint(equalTo: view.leadingAnchor),
shield.trailingAnchor.constraint(equalTo: view.trailingAnchor),
shield.topAnchor.constraint(equalTo: view.topAnchor),
shield.bottomAnchor.constraint(equalTo: view.bottomAnchor),
shieldLabel.leadingAnchor.constraint(equalTo: shield.contentView.leadingAnchor, constant: 32),
shieldLabel.trailingAnchor.constraint(equalTo: shield.contentView.trailingAnchor, constant: -32),
shieldLabel.centerYAnchor.constraint(equalTo: shield.contentView.centerYAnchor),
])
NotificationCenter.default.addObserver(
self,
selector: #selector(appWillResignActive),
name: UIApplication.willResignActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
registerForTraitChanges([UITraitSceneCaptureState.self]) {
(controller: KeyImportScannerViewController, _: UITraitCollection) in
controller.updateCaptureShield()
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if !completing {
do {
try scanner.startScanning()
} catch {
presentFailure(error.localizedDescription)
}
}
}
override func viewWillDisappear(_ animated: Bool) {
scanner.stopScanning()
super.viewWillDisappear(animated)
}
func dataScanner(
_ dataScanner: DataScannerViewController,
didAdd addedItems: [RecognizedItem],
allItems: [RecognizedItem]
) {
guard !completing else { return }
for item in addedItems {
guard case let .barcode(barcode) = item, let payload = barcode.payloadStringValue else {
continue
}
do {
let progress = try importer.addFrame(payload: payload)
if progress.duplicate {
progressLabel.text = "Already scanned — show the next QR code"
} else {
progressLabel.text = progress.key == nil
? "Scanned \(progress.received) of \(progress.total)"
: "Key transfer complete"
}
UIAccessibility.post(notification: .announcement, argument: progressLabel.text)
if let key = progress.key {
completing = true
scanner.stopScanning()
confirmImport(key)
return
}
} catch {
progressLabel.text = "That QR code could not be added"
UIAccessibility.post(notification: .announcement, argument: progressLabel.text)
presentFailure(error.localizedDescription)
}
}
}
func dataScanner(
_ dataScanner: DataScannerViewController,
becameUnavailableWithError error: DataScannerViewController.ScanningUnavailable
) {
presentFailure("Live scanning became unavailable. Try again when the camera is free.")
}
@objc private func appWillResignActive() {
shield.isHidden = false
scanner.stopScanning()
}
@objc private func appDidBecomeActive() {
updateCaptureShield()
}
private func updateCaptureShield() {
let captured = traitCollection.sceneCaptureState == .active
shield.isHidden = !captured
if captured || completing || view.window == nil {
scanner.stopScanning()
} else {
try? scanner.startScanning()
}
}
private func confirmImport(_ key: MobileKeyTransferKey) {
let privateKey = key.kind == .private
let warning = privateKey
? "Import this private key only if you trust the device that displayed it."
: "Import this public key?"
let alert = UIAlertController(
title: privateKey ? "Import Private GPG Key?" : "Import Public GPG Key?",
message: "\(warning)\n\n\(key.title)\n\(key.detail)",
preferredStyle: .alert
)
if privateKey && key.requiresPassphrase {
alert.addTextField { field in
field.placeholder = "GPG key passphrase"
field.isSecureTextEntry = true
field.textContentType = .password
field.clearButtonMode = .whileEditing
}
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in
self?.navigationController?.popViewController(animated: true)
})
alert.addAction(UIAlertAction(title: "Import", style: .default) { [weak self, weak alert] _ in
self?.finishImport(passphrase: alert?.textFields?.first?.text, makeDefault: false)
})
if privateKey {
alert.addAction(UIAlertAction(title: "Import as Default", style: .default) {
[weak self, weak alert] _ in
self?.finishImport(passphrase: alert?.textFields?.first?.text, makeDefault: true)
})
}
present(alert, animated: true)
}
private func finishImport(passphrase: String?, makeDefault: Bool) {
do {
let outcome = try importer.import(passphrase: passphrase, makeDefault: makeDefault)
let alert = UIAlertController(
title: outcome.title,
message: outcome.detail,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Done", style: .default) { [weak self] _ in
guard self != nil else { return }
NotificationCenter.default.post(name: .ironStorageKeyMaterialDidChange, object: nil)
})
present(alert, animated: true)
} catch {
completing = false
presentFailure(error.localizedDescription) { [weak self] in
guard let self else { return }
try? scanner.startScanning()
}
}
}
private func presentFailure(_ detail: String, completion: (() -> Void)? = nil) {
guard presentedViewController == nil else { return }
let alert = UIAlertController(
title: "GPG Key Transfer Failed",
message: detail,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in completion?() })
present(alert, animated: true)
}
}
@MainActor
private final class KeyExportListViewController: UITableViewController {
private let transfer: MobileKeyTransfer
private let keys: [MobileKeyTransferKey]
init(transfer: MobileKeyTransfer) {
self.transfer = transfer
keys = transfer.keys()
super.init(style: .insetGrouped)
title = "Export GPG Key"
navigationItem.largeTitleDisplayMode = .never
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
override func viewDidLoad() {
super.viewDidLoad()
if keys.isEmpty {
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "key.slash")
configuration.text = "No GPG Keys"
configuration.secondaryText = "Import a key before exporting one."
contentUnavailableConfiguration = configuration
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
keys.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let key = keys[indexPath.row]
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
var content = cell.defaultContentConfiguration()
content.image = UIImage(systemName: key.kind == .private ? "key.fill" : "key")
content.text = key.title
content.secondaryText = key.detail
content.secondaryTextProperties.numberOfLines = 0
cell.contentConfiguration = content
cell.accessoryType = .disclosureIndicator
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let key = keys[indexPath.row]
let sheet = UIAlertController(title: key.title, message: key.detail, preferredStyle: .actionSheet)
sheet.addAction(UIAlertAction(title: "Export Public Key", style: .default) {
[weak self] _ in self?.export(key, kind: .public, passphrase: nil)
})
if key.kind == .private {
sheet.addAction(UIAlertAction(title: "Export Private Key…", style: .destructive) {
[weak self] _ in self?.confirmPrivateExport(key)
})
}
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
if let popover = sheet.popoverPresentationController {
popover.sourceView = tableView.cellForRow(at: indexPath)
popover.sourceRect = tableView.cellForRow(at: indexPath)?.bounds ?? .zero
}
present(sheet, animated: true)
}
private func confirmPrivateExport(_ key: MobileKeyTransferKey) {
let alert = UIAlertController(
title: "Display Private GPG Key?",
message: "Anyone who scans these QR codes can use this private key.\n\n\(key.title)\n\(key.detail)",
preferredStyle: .alert
)
if key.requiresPassphrase {
alert.addTextField { field in
field.placeholder = "GPG key passphrase"
field.isSecureTextEntry = true
field.textContentType = .password
field.clearButtonMode = .whileEditing
}
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Display", style: .destructive) {
[weak self, weak alert] _ in
self?.export(key, kind: .private, passphrase: alert?.textFields?.first?.text)
})
present(alert, animated: true)
}
private func export(
_ key: MobileKeyTransferKey,
kind: MobileKeyTransferKind,
passphrase: String?
) {
do {
let exported = try transfer.export(
fingerprint: key.fingerprint,
kind: kind,
passphrase: passphrase
)
navigationController?.pushViewController(
KeyQrExportViewController(exported: exported),
animated: true
)
} catch {
let alert = UIAlertController(
title: "GPG Key Could Not Be Exported",
message: error.localizedDescription,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
}
}
@MainActor
private final class KeyQrExportViewController: UIViewController {
private let exported: MobileKeyTransferExport
private let qrView = KeyQrView()
private let pageControl = UIPageControl()
private let progressLabel = UILabel()
private let shield = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial))
private var index = 0
init(exported: MobileKeyTransferExport) {
self.exported = exported
super.init(nibName: nil, bundle: nil)
title = exported.key.kind == .private ? "Private GPG Key" : "Public GPG Key"
navigationItem.largeTitleDisplayMode = .never
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
deinit {
NotificationCenter.default.removeObserver(self)
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
qrView.translatesAutoresizingMaskIntoConstraints = false
qrView.layer.cornerRadius = 16
qrView.clipsToBounds = true
let identity = UILabel()
identity.text = exported.key.title
identity.font = .preferredFont(forTextStyle: .headline)
identity.adjustsFontForContentSizeCategory = true
identity.numberOfLines = 0
identity.textAlignment = .center
let fingerprint = UILabel()
fingerprint.text = exported.key.detail
fingerprint.font = .preferredFont(forTextStyle: .footnote)
fingerprint.adjustsFontForContentSizeCategory = true
fingerprint.numberOfLines = 0
fingerprint.textAlignment = .center
fingerprint.textColor = .secondaryLabel
progressLabel.font = .preferredFont(forTextStyle: .callout)
progressLabel.adjustsFontForContentSizeCategory = true
progressLabel.textAlignment = .center
pageControl.numberOfPages = exported.frames.count
pageControl.addTarget(self, action: #selector(pageChanged), for: .valueChanged)
pageControl.isHidden = exported.frames.count == 1
let stack = UIStackView(arrangedSubviews: [identity, fingerprint, qrView, progressLabel, pageControl])
stack.axis = .vertical
stack.alignment = .fill
stack.spacing = 14
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
let shieldLabel = UILabel()
shieldLabel.text = "Private key hidden while IronStorage is inactive or the screen is captured"
shieldLabel.font = .preferredFont(forTextStyle: .headline)
shieldLabel.adjustsFontForContentSizeCategory = true
shieldLabel.numberOfLines = 0
shieldLabel.textAlignment = .center
shieldLabel.translatesAutoresizingMaskIntoConstraints = false
shield.contentView.addSubview(shieldLabel)
shield.translatesAutoresizingMaskIntoConstraints = false
shield.isHidden = true
shield.accessibilityViewIsModal = true
view.addSubview(shield)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 24),
stack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -24),
stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
qrView.widthAnchor.constraint(equalTo: qrView.heightAnchor),
qrView.widthAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.widthAnchor, constant: -48),
shield.leadingAnchor.constraint(equalTo: view.leadingAnchor),
shield.trailingAnchor.constraint(equalTo: view.trailingAnchor),
shield.topAnchor.constraint(equalTo: view.topAnchor),
shield.bottomAnchor.constraint(equalTo: view.bottomAnchor),
shieldLabel.leadingAnchor.constraint(equalTo: shield.contentView.leadingAnchor, constant: 32),
shieldLabel.trailingAnchor.constraint(equalTo: shield.contentView.trailingAnchor, constant: -32),
shieldLabel.centerYAnchor.constraint(equalTo: shield.contentView.centerYAnchor),
])
let left = UISwipeGestureRecognizer(target: self, action: #selector(swipedLeft))
left.direction = .left
let right = UISwipeGestureRecognizer(target: self, action: #selector(swipedRight))
right.direction = .right
qrView.addGestureRecognizer(left)
qrView.addGestureRecognizer(right)
qrView.isUserInteractionEnabled = true
NotificationCenter.default.addObserver(
self,
selector: #selector(appWillResignActive),
name: UIApplication.willResignActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
registerForTraitChanges([UITraitSceneCaptureState.self]) {
(controller: KeyQrExportViewController, _: UITraitCollection) in
controller.updateCaptureShield()
}
showFrame(0)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
updateCaptureShield()
}
@objc private func pageChanged() {
showFrame(pageControl.currentPage)
}
@objc private func swipedLeft() {
showFrame(min(index + 1, exported.frames.count - 1))
}
@objc private func swipedRight() {
showFrame(max(index - 1, 0))
}
private func showFrame(_ index: Int) {
self.index = index
pageControl.currentPage = index
qrView.transferFrame = exported.frames[index]
progressLabel.text = exported.frames.count == 1
? "Scan this QR code"
: "QR code \(index + 1) of \(exported.frames.count)"
qrView.accessibilityLabel = progressLabel.text
UIAccessibility.post(notification: .pageScrolled, argument: progressLabel.text)
}
@objc private func appWillResignActive() {
if exported.key.kind == .private {
shield.isHidden = false
}
}
@objc private func appDidBecomeActive() {
updateCaptureShield()
}
private func updateCaptureShield() {
shield.isHidden = exported.key.kind != .private || traitCollection.sceneCaptureState != .active
}
}
private final class KeyQrView: UIView {
var transferFrame: MobileKeyTransferFrame? {
didSet { setNeedsDisplay() }
}
override func draw(_ rect: CGRect) {
UIColor.white.setFill()
UIRectFill(rect)
guard let transferFrame, transferFrame.width > 0 else { return }
let width = Int(transferFrame.width)
let padded = width + 8
let scale = floor(min(rect.width, rect.height) / CGFloat(padded))
guard scale >= 1 else { return }
let symbolSize = CGFloat(padded) * scale
let origin = CGPoint(
x: rect.midX - symbolSize / 2 + 4 * scale,
y: rect.midY - symbolSize / 2 + 4 * scale
)
guard let context = UIGraphicsGetCurrentContext() else { return }
context.setFillColor(UIColor.black.cgColor)
context.interpolationQuality = .none
for y in 0..<width {
for x in 0..<width where transferFrame.modules[y * width + x] != 0 {
context.fill(CGRect(
x: origin.x + CGFloat(x) * scale,
y: origin.y + CGFloat(y) * scale,
width: scale,
height: scale
))
}
}
}
}
@MainActor
private final class TotpListViewController: UITableViewController, MobileTabRoot {
fileprivate let shellTab = MobileTab.totp