diff --git a/Authenticator.xcodeproj/project.pbxproj b/Authenticator.xcodeproj/project.pbxproj index 0f75a27e..29523f81 100644 --- a/Authenticator.xcodeproj/project.pbxproj +++ b/Authenticator.xcodeproj/project.pbxproj @@ -51,7 +51,7 @@ C9CC09551BA91D1C008C54FE /* TableViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9CC09541BA91D1C008C54FE /* TableViewModel.swift */; }; C9D6C83F1906BD68004F0E08 /* SegmentedControlRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9D6C83E1906BD68004F0E08 /* SegmentedControlRow.swift */; }; C9D6C8461906CD54004F0E08 /* TextFieldRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9D6C8451906CD54004F0E08 /* TextFieldRow.swift */; }; - C9D6C84C19075044004F0E08 /* OTPProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9D6C84B19075044004F0E08 /* OTPProgressRing.swift */; }; + C9D6C84C19075044004F0E08 /* ProgressRingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9D6C84B19075044004F0E08 /* ProgressRingView.swift */; }; C9DE02E71ED2234D00D7E01C /* InfoList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9DE02E61ED2234D00D7E01C /* InfoList.swift */; }; C9DE02E91ED227D600D7E01C /* InfoListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9DE02E81ED227D600D7E01C /* InfoListViewController.swift */; }; C9E3FB9A1E281CBC00EFA8BB /* TokenScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9E3FB991E281CBC00EFA8BB /* TokenScanner.swift */; }; @@ -172,7 +172,7 @@ C9CC09541BA91D1C008C54FE /* TableViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TableViewModel.swift; sourceTree = ""; }; C9D6C83E1906BD68004F0E08 /* SegmentedControlRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SegmentedControlRow.swift; sourceTree = ""; }; C9D6C8451906CD54004F0E08 /* TextFieldRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextFieldRow.swift; sourceTree = ""; }; - C9D6C84B19075044004F0E08 /* OTPProgressRing.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OTPProgressRing.swift; sourceTree = ""; }; + C9D6C84B19075044004F0E08 /* ProgressRingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgressRingView.swift; sourceTree = ""; }; C9D844341D4C576B00D5E343 /* CONTRIBUTING.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CONTRIBUTING.md; sourceTree = ""; }; C9D844361D4C59D600D5E343 /* CONDUCT.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CONDUCT.md; sourceTree = ""; }; C9DE02E61ED2234D00D7E01C /* InfoList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InfoList.swift; sourceTree = ""; }; @@ -400,7 +400,7 @@ C9B7328E1C0A8AE60076F77E /* TokenListViewModel.swift */, C92708AB19CFB0750033128B /* TokenListViewController.swift */, C98C1C4517D2EDF500A07D3F /* Cells */, - C9D6C84B19075044004F0E08 /* OTPProgressRing.swift */, + C9D6C84B19075044004F0E08 /* ProgressRingView.swift */, CC46C4701DB3007D00EB4605 /* SearchField.swift */, ); name = "Token List"; @@ -669,7 +669,7 @@ C93BD6231C167CD100FFFB8F /* Root.swift in Sources */, C97CDF2C1BEEC90100D64406 /* QRScanner.swift in Sources */, C9AAB07F1B917EC3000CE547 /* TokenEditForm.swift in Sources */, - C9D6C84C19075044004F0E08 /* OTPProgressRing.swift in Sources */, + C9D6C84C19075044004F0E08 /* ProgressRingView.swift in Sources */, C9A1C1A91E501D8B009E65D6 /* Info.swift in Sources */, C9CC09531BA9133B008C54FE /* FocusCell.swift in Sources */, C99069D1180CBAC900BAEF53 /* TokenScannerViewController.swift in Sources */, @@ -745,6 +745,7 @@ INFOPLIST_FILE = Authenticator/Resources/Info.plist; PRODUCT_BUNDLE_IDENTIFIER = me.mattrubin.authenticator.dev; PRODUCT_NAME = Authenticator; + SWIFT_TREAT_WARNINGS_AS_ERRORS = NO; TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; }; diff --git a/Authenticator/Resources/Acknowledgements.html b/Authenticator/Resources/Acknowledgements.html index 85c04966..f7ba940d 100644 --- a/Authenticator/Resources/Acknowledgements.html +++ b/Authenticator/Resources/Acknowledgements.html @@ -53,25 +53,10 @@

SVProgressHUD

-

Copyright (c) 2011-2016 Sam Vermette, Tobias Tiemerding and contributors.

-

Permission is hereby granted, free of charge, to any person - obtaining a copy of this software and associated documentation - files (the "Software"), to deal in the Software without - restriction, including without limitation the rights to use, - copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the - Software is furnished to do so, subject to the following - conditions:

-

The above copyright notice and this permission notice shall be - included in all copies or substantial portions of the Software.

-

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES - OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR - OTHER DEALINGS IN THE SOFTWARE.

+

Copyright (c) 2011-2017 Sam Vermette, Tobias Tiemerding and contributors.

+

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

+

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

+

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

xcconfigs @@ -80,6 +65,6 @@

Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means.

In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

-

For more information, please refer to unlicense.org.

+

For more information, please refer to unlicense.org.

diff --git a/Authenticator/Resources/Info.plist b/Authenticator/Resources/Info.plist index 8ae3b0f5..d69567df 100644 --- a/Authenticator/Resources/Info.plist +++ b/Authenticator/Resources/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2.0.1 + 2.0.2 CFBundleSignature ???? CFBundleURLTypes diff --git a/Authenticator/Source/AppController.swift b/Authenticator/Source/AppController.swift index 745c1c80..414f7e94 100644 --- a/Authenticator/Source/AppController.swift +++ b/Authenticator/Source/AppController.swift @@ -33,26 +33,24 @@ class AppController { private let store: TokenStore private var component: Root { didSet { - let viewModel = currentViewModel() - // TODO: Fix the excessive updates of bar button items so that the tick can run while they are on screen. - if case .none = viewModel.modal { - if displayLink == nil { - startTick() - } - } else { - if displayLink != nil { - stopTick() - } - } - view.updateWithViewModel(viewModel) + updateView() } } private lazy var view: RootViewController = { + let (currentViewModel, nextRefreshTime) = self.component.viewModel(for: self.store.persistentTokens, + at: .currentDisplayTime()) + self.setTimer(withNextRefreshTime: nextRefreshTime) return RootViewController( - viewModel: self.currentViewModel(), + viewModel: currentViewModel, dispatchAction: self.handleAction ) }() + private var refreshTimer: Timer? { + willSet { + // Invalidate the old timer + refreshTimer?.invalidate() + } + } init() { do { @@ -73,33 +71,26 @@ class AppController { // If this is a demo, show the scanner even in the simulator. let deviceCanScan = QRScanner.deviceCanScan || CommandLine.isDemo component = Root(deviceCanScan: deviceCanScan) - - startTick() - } - - private func currentViewModel() -> Root.ViewModel { - return component.viewModel(for: store.persistentTokens, at: .currentDisplayTime()) } - // MARK: - Tick - - private var displayLink: CADisplayLink? - - private func startTick() { - let selector = #selector(tick) - self.displayLink = CADisplayLink(target: self, selector: selector) - self.displayLink?.add(to: RunLoop.main, forMode: .commonModes) - } - - private func stopTick() { - self.displayLink?.invalidate() - self.displayLink = nil + @objc + func updateView() { + let (currentViewModel, nextRefreshTime) = component.viewModel(for: store.persistentTokens, + at: .currentDisplayTime()) + setTimer(withNextRefreshTime: nextRefreshTime) + view.updateWithViewModel(currentViewModel) } - @objc - func tick() { - // Update the view with a new view model for the current display time. - view.updateWithViewModel(currentViewModel()) + private func setTimer(withNextRefreshTime nextRefreshTime: Date) { + let timer = Timer(fireAt: nextRefreshTime, + interval: 0, + target: self, + selector: #selector(updateView), + userInfo: nil, + repeats: false) + // Add the new timer to the main run loop + RunLoop.main.add(timer, forMode: .commonModes) + refreshTimer = timer } // MARK: - Update @@ -143,7 +134,7 @@ class AppController { case let .updatePersistentToken(persistentToken, failure): do { try store.updatePersistentToken(persistentToken) - view.updateWithViewModel(currentViewModel()) + updateView() } catch { handleEvent(failure(error)) } @@ -151,7 +142,7 @@ class AppController { case let .moveToken(fromIndex, toIndex, failure): do { try store.moveTokenFromIndex(fromIndex, toIndex: toIndex) - view.updateWithViewModel(currentViewModel()) + updateView() } catch { handleEvent(failure(error)) } @@ -159,7 +150,7 @@ class AppController { case let .deletePersistentToken(persistentToken, failure): do { try store.deletePersistentToken(persistentToken) - view.updateWithViewModel(currentViewModel()) + updateView() } catch { handleEvent(failure(error)) } diff --git a/Authenticator/Source/OTPAppDelegate.swift b/Authenticator/Source/OTPAppDelegate.swift index f6c897c6..bcc5454f 100644 --- a/Authenticator/Source/OTPAppDelegate.swift +++ b/Authenticator/Source/OTPAppDelegate.swift @@ -38,7 +38,6 @@ class OTPAppDelegate: UIResponder, UIApplicationDelegate { let fontAttributes = [NSFontAttributeName: barButtonItemFont] UIBarButtonItem.appearance().setTitleTextAttributes(fontAttributes, for: .normal) UIBarButtonItem.appearance().setTitleTextAttributes(fontAttributes, for: .highlighted) - UIBarButtonItem.appearance().setTitleTextAttributes(fontAttributes, for: .selected) let disabledAttributes = [ NSFontAttributeName: barButtonItemFont, @@ -57,6 +56,11 @@ class OTPAppDelegate: UIResponder, UIApplicationDelegate { return true } + func applicationWillEnterForeground(_ application: UIApplication) { + // Ensure the UI is updated with the latest view model whenever the app returns from the background. + app.updateView() + } + func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool { if let token = Token(url: url) { let message = "Do you want to add a token for “\(token.name)”?" diff --git a/Authenticator/Source/OTPProgressRing.swift b/Authenticator/Source/OTPProgressRing.swift deleted file mode 100644 index 4df45185..00000000 --- a/Authenticator/Source/OTPProgressRing.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// OTPProgressRing.swift -// Authenticator -// -// Copyright (c) 2014-2016 Authenticator authors -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// - -import UIKit - -class OTPProgressRing: UIView { - private let lineWidth: CGFloat = 1.5 - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - } - - override init(frame: CGRect) { - super.init(frame: frame) - self.isOpaque = false - } - - var progress: Double = 0 { - didSet { - self.setNeedsDisplay() - } - } - - override func draw(_ rect: CGRect) { - guard let context = UIGraphicsGetCurrentContext() else { - return - } - - let halfLineWidth = lineWidth / 2 - let ringRect = self.bounds.insetBy(dx: halfLineWidth, dy: halfLineWidth) - - context.setLineWidth(lineWidth) - - context.setStrokeColor(self.tintColor.withAlphaComponent(0.2).cgColor) - context.strokeEllipse(in: ringRect) - - context.setStrokeColor(self.tintColor.cgColor) - let startAngle: CGFloat = -.pi / 2 - context.addArc(center: CGPoint(x: ringRect.midX, y: ringRect.midY), - radius: ringRect.width / 2, - startAngle: startAngle, - endAngle: 2 * .pi * CGFloat(self.progress) + startAngle, - clockwise: true) - context.strokePath() - } -} diff --git a/Authenticator/Source/ProgressRingView.swift b/Authenticator/Source/ProgressRingView.swift new file mode 100644 index 00000000..fe6434ff --- /dev/null +++ b/Authenticator/Source/ProgressRingView.swift @@ -0,0 +1,126 @@ +// +// ProgressRingView.swift +// Authenticator +// +// Copyright (c) 2014-2016 Authenticator authors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import UIKit + +struct ProgressRingViewModel { + let startTime: Date + let endTime: Date + + var duration: TimeInterval { + return endTime.timeIntervalSince(startTime) + } +} + +class ProgressRingView: UIView { + private let backgroundRingLayer = RingLayer() + private let foregroundRingLayer = RingLayer() + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + configureSublayers() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + configureSublayers() + } + + private func configureSublayers() { + layer.addSublayer(backgroundRingLayer) + layer.addSublayer(foregroundRingLayer) + updateWithRingColor(tintColor) + } + + // MARK: Layout + + override func layoutSublayers(of layer: CALayer) { + super.layoutSublayers(of: layer) + // Lay out the ring layers to fill the view's layer. + if layer == self.layer { + backgroundRingLayer.frame = layer.bounds + foregroundRingLayer.frame = layer.bounds + } + } + + // MARK: Update + + override func tintColorDidChange() { + super.tintColorDidChange() + updateWithRingColor(tintColor) + } + + private func updateWithRingColor(_ ringColor: UIColor) { + foregroundRingLayer.strokeColor = ringColor.cgColor + backgroundRingLayer.strokeColor = ringColor.withAlphaComponent(0.2).cgColor + } + + func updateWithViewModel(_ viewModel: ProgressRingViewModel) { + let path = #keyPath(RingLayer.strokeStart) + let animation = CABasicAnimation(keyPath: path) + let now = layer.convertTime(CACurrentMediaTime(), from: nil) + animation.beginTime = now + viewModel.startTime.timeIntervalSinceNow + if CommandLine.isDemo { + animation.beginTime -= DisplayTime.demoTime.date.timeIntervalSinceNow + } + animation.duration = viewModel.duration + animation.fromValue = 0 + animation.toValue = 1 + foregroundRingLayer.add(animation, forKey: path) + } +} + +private class RingLayer: CAShapeLayer { + override init() { + super.init() + lineWidth = 1.5 + fillColor = nil + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + override func layoutSublayers() { + super.layoutSublayers() + + // Inset the ring to draw within the layer's bounds. + let halfLineWidth = lineWidth / 2 + let ringRect = bounds.insetBy(dx: halfLineWidth, dy: halfLineWidth) + + // Transform the ring path to draw clockwise, starting at the top. + let translationToOrigin = CGAffineTransform(translationX: -ringRect.midX, y: -ringRect.midY) + // Note: The rotation angle is more precisely expressed as `-.pi / 2`, but that causes a bug on 32-bit devices. + // See https://github.com/mattrubin/Authenticator/issues/235 for more details. + let rotation = CGAffineTransform(rotationAngle: -1.5708) + let translationFromOrigin = CGAffineTransform(translationX: ringRect.midX, y: ringRect.midY) + var transform = translationToOrigin.concatenating(rotation).concatenating(translationFromOrigin) + withUnsafePointer(to: &transform) { transform in + path = CGPath(ellipseIn: ringRect, transform: transform) + } + } +} diff --git a/Authenticator/Source/Root.swift b/Authenticator/Source/Root.swift index a9ed94b6..3f21a1f1 100644 --- a/Authenticator/Source/Root.swift +++ b/Authenticator/Source/Root.swift @@ -66,11 +66,13 @@ struct Root: Component { extension Root { typealias ViewModel = RootViewModel - func viewModel(for persistentTokens: [PersistentToken], at displayTime: DisplayTime) -> ViewModel { - return ViewModel( - tokenList: tokenList.viewModel(for: persistentTokens, at: displayTime), + func viewModel(for persistentTokens: [PersistentToken], at displayTime: DisplayTime) -> (viewModel: ViewModel, nextRefreshTime: Date) { + let (tokenListViewModel, nextRefreshTime) = tokenList.viewModel(for: persistentTokens, at: displayTime) + let viewModel = ViewModel( + tokenList: tokenListViewModel, modal: modal.viewModel ) + return (viewModel: viewModel, nextRefreshTime: nextRefreshTime) } } diff --git a/Authenticator/Source/SearchField.swift b/Authenticator/Source/SearchField.swift index 127a0630..41a0794e 100644 --- a/Authenticator/Source/SearchField.swift +++ b/Authenticator/Source/SearchField.swift @@ -28,7 +28,7 @@ import UIKit // A custom view that contains a SearchTextField displaying its placeholder centered in the // text field. // -// Displays a OTPProgressRing as the `leftView` control. +// Displays a ProgressRingView as the `leftView` control. class SearchField: UIView { override init(frame: CGRect) { @@ -50,7 +50,7 @@ class SearchField: UIView { return textField.text } - let ring = OTPProgressRing( + let ring = ProgressRingView( frame: CGRect(origin: .zero, size: CGSize(width: 22, height: 22)) ) @@ -103,11 +103,11 @@ class SearchField: UIView { // MARK: TokenListPresenter extension SearchField { func updateWithViewModel(_ viewModel: TokenList.ViewModel) { - if let ringProgress = viewModel.ringProgress { - ring.progress = ringProgress + if let progressRingViewModel = viewModel.progressRingViewModel { + ring.updateWithViewModel(progressRingViewModel) } // Show the countdown ring only if a time-based token is active - textField.leftViewMode = viewModel.ringProgress != nil ? .always : .never + textField.leftViewMode = viewModel.progressRingViewModel != nil ? .always : .never // Only display text field as editable if there are tokens to filter textField.isEnabled = viewModel.hasTokens diff --git a/Authenticator/Source/TextFieldRow.swift b/Authenticator/Source/TextFieldRow.swift index 15f8e75d..3f280e47 100644 --- a/Authenticator/Source/TextFieldRow.swift +++ b/Authenticator/Source/TextFieldRow.swift @@ -73,6 +73,8 @@ class TextFieldRowCell: UITableViewCell, UITextFieldDelegate { textField.borderStyle = .roundedRect textField.font = UIFont.systemFont(ofSize: 16, weight: UIFontWeightLight) contentView.addSubview(textField) + + accessibilityElements = [textField] } override func layoutSubviews() { @@ -101,6 +103,8 @@ class TextFieldRowCell: UITableViewCell, UITextFieldDelegate { textField.text = viewModel.value } changeAction = viewModel.changeAction + + textField.accessibilityLabel = viewModel.label } static func heightWithViewModel(_ viewModel: TextFieldRowViewModel) -> CGFloat { diff --git a/Authenticator/Source/TokenList.swift b/Authenticator/Source/TokenList.swift index e3446957..be65239d 100644 --- a/Authenticator/Source/TokenList.swift +++ b/Authenticator/Source/TokenList.swift @@ -35,42 +35,28 @@ struct TokenList: Component { typealias ViewModel = TokenListViewModel - func viewModel(for persistentTokens: [PersistentToken], at displayTime: DisplayTime) -> TokenListViewModel { + func viewModel(for persistentTokens: [PersistentToken], at displayTime: DisplayTime) -> (viewModel: TokenListViewModel, nextRefreshTime: Date) { let isFiltering = !(filter ?? "").isEmpty let rowModels = filteredTokens(persistentTokens).map({ TokenRowModel(persistentToken: $0, displayTime: displayTime, canReorder: !isFiltering) }) - return TokenListViewModel( + + let lastRefreshTime = persistentTokens.reduce(.distantPast) { (lastRefreshTime, persistentToken) in + max(lastRefreshTime, persistentToken.lastRefreshTime(before: displayTime)) + } + let nextRefreshTime = persistentTokens.reduce(.distantFuture) { (nextRefreshTime, persistentToken) in + min(nextRefreshTime, persistentToken.nextRefreshTime(after: displayTime)) + } + + let viewModel = TokenListViewModel( rowModels: rowModels, - ringProgress: ringProgress(for: persistentTokens, at: displayTime), + progressRingViewModel: persistentTokens.isEmpty ? nil : + ProgressRingViewModel(startTime: lastRefreshTime, endTime: nextRefreshTime), totalTokens: persistentTokens.count, isFiltering: isFiltering ) - } - /// Returns a sorted, uniqued array of the periods of timer-based tokens - private func timeBasedTokenPeriods(for persistentTokens: [PersistentToken]) -> [TimeInterval] { - var periods = Set() - persistentTokens.forEach { (persistentToken) in - if case .timer(let period) = persistentToken.token.generator.factor { - periods.insert(period) - } - } - return Array(periods).sorted() - } - - private func ringProgress(for persistentTokens: [PersistentToken], at displayTime: DisplayTime) -> Double? { - guard let ringPeriod = timeBasedTokenPeriods(for: persistentTokens).first else { - // If there are no time-based tokens, return nil to hide the progress ring. - return nil - } - guard ringPeriod > 0 else { - // If the period is >= zero, return zero to display the ring but avoid the potential - // divide-by-zero error below. - return 0 - } - // Calculate the percentage progress in the current period. - return fmod(displayTime.timeIntervalSince1970, ringPeriod) / ringPeriod + return (viewModel: viewModel, nextRefreshTime: nextRefreshTime) } private func filteredTokens(_ persistentTokens: [PersistentToken]) -> [PersistentToken] { @@ -192,3 +178,25 @@ func == (lhs: TokenList.Action, rhs: TokenList.Action) -> Bool { return false } } + +private extension PersistentToken { + func lastRefreshTime(before displayTime: DisplayTime) -> Date { + switch token.generator.factor { + case .counter: + return .distantPast + case .timer(let period): + let epoch = displayTime.timeIntervalSince1970 + return Date(timeIntervalSince1970: epoch - epoch.truncatingRemainder(dividingBy: period)) + } + } + + func nextRefreshTime(after displayTime: DisplayTime) -> Date { + switch token.generator.factor { + case .counter: + return .distantFuture + case .timer(let period): + let epoch = displayTime.timeIntervalSince1970 + return Date(timeIntervalSince1970: epoch + (period - epoch.truncatingRemainder(dividingBy: period))) + } + } +} diff --git a/Authenticator/Source/TokenListViewController.swift b/Authenticator/Source/TokenListViewController.swift index 63fc6b1e..72d05cba 100644 --- a/Authenticator/Source/TokenListViewController.swift +++ b/Authenticator/Source/TokenListViewController.swift @@ -166,6 +166,7 @@ class TokenListViewController: UITableViewController { let searchSelector = #selector(TokenListViewController.filterTokens) searchBar.textField.addTarget(self, action: searchSelector, for: .editingChanged) + searchBar.updateWithViewModel(viewModel) } override func viewWillDisappear(_ animated: Bool) { diff --git a/Authenticator/Source/TokenListViewModel.swift b/Authenticator/Source/TokenListViewModel.swift index 50922398..8e78e178 100644 --- a/Authenticator/Source/TokenListViewModel.swift +++ b/Authenticator/Source/TokenListViewModel.swift @@ -27,7 +27,7 @@ import Foundation struct TokenListViewModel { let rowModels: [TokenRowModel] - let ringProgress: Double? + let progressRingViewModel: ProgressRingViewModel? let totalTokens: Int let isFiltering: Bool var hasTokens: Bool { diff --git a/Authenticator/Source/TokenScannerViewController.swift b/Authenticator/Source/TokenScannerViewController.swift index 97420cde..487920bd 100644 --- a/Authenticator/Source/TokenScannerViewController.swift +++ b/Authenticator/Source/TokenScannerViewController.swift @@ -99,11 +99,13 @@ final class TokenScannerViewController: UIViewController, QRScannerDelegate { target: self, action: #selector(TokenScannerViewController.cancel) ) - navigationItem.rightBarButtonItem = UIBarButtonItem( + let manualEntryBarButtonItem = UIBarButtonItem( barButtonSystemItem: .compose, target: self, action: #selector(TokenScannerViewController.addTokenManually) ) + manualEntryBarButtonItem.accessibilityLabel = "Manual token entry" + navigationItem.rightBarButtonItem = manualEntryBarButtonItem videoLayer.videoGravity = AVLayerVideoGravityResizeAspectFill videoLayer.frame = view.layer.bounds diff --git a/AuthenticatorScreenshots/AuthenticatorScreenshots.swift b/AuthenticatorScreenshots/AuthenticatorScreenshots.swift index e500dd25..f4d694dd 100644 --- a/AuthenticatorScreenshots/AuthenticatorScreenshots.swift +++ b/AuthenticatorScreenshots/AuthenticatorScreenshots.swift @@ -64,7 +64,7 @@ class AuthenticatorScreenshots: XCTestCase { // Take a screenshot of the token scanner. snapshot("1-ScanToken") - app.navigationBars.buttons["Compose"].tap() + app.navigationBars.buttons["Manual token entry"].tap() // Wait for the scroll bars to fade. sleep(1) // Take a screenshot of the token entry form. diff --git a/AuthenticatorTests/RootTests.swift b/AuthenticatorTests/RootTests.swift index 59a03592..f08693df 100644 --- a/AuthenticatorTests/RootTests.swift +++ b/AuthenticatorTests/RootTests.swift @@ -33,7 +33,7 @@ class RootTests: XCTestCase { var root = Root(deviceCanScan: false) // Ensure there is no modal visible. - let firstViewModel = root.viewModel(for: [], at: displayTime) + let (firstViewModel, _) = root.viewModel(for: [], at: displayTime) guard case .none = firstViewModel.modal else { XCTFail("Expected .none, got \(firstViewModel.modal)") return @@ -51,7 +51,7 @@ class RootTests: XCTestCase { XCTAssertNil(showEffect) // Ensure the backup info modal is visible. - let secondViewModel = root.viewModel(for: [], at: displayTime) + let (secondViewModel, _) = root.viewModel(for: [], at: displayTime) switch secondViewModel.modal { case .info(_, .some(let infoViewModel)): XCTAssert(infoViewModel.title == "Backups") @@ -71,7 +71,7 @@ class RootTests: XCTestCase { XCTAssertNil(hideEffect) // Ensure the backup info modal no longer visible. - let thirdViewModel = root.viewModel(for: [], at: displayTime) + let (thirdViewModel, _) = root.viewModel(for: [], at: displayTime) guard case .none = thirdViewModel.modal else { XCTFail("Expected .none, got \(thirdViewModel.modal)") return @@ -82,7 +82,7 @@ class RootTests: XCTestCase { var root = Root(deviceCanScan: false) // Ensure there is no modal visible. - let firstViewModel = root.viewModel(for: [], at: displayTime) + let (firstViewModel, _) = root.viewModel(for: [], at: displayTime) guard case .none = firstViewModel.modal else { XCTFail("Expected .none, got \(firstViewModel.modal)") return @@ -100,7 +100,7 @@ class RootTests: XCTestCase { XCTAssertNil(showInfoEffect) // Ensure the info list modal is visible. - let nextViewModel = root.viewModel(for: [], at: displayTime) + let (nextViewModel, _) = root.viewModel(for: [], at: displayTime) guard case .info(_, .none) = nextViewModel.modal else { XCTFail("Expected .info list, got \(nextViewModel.modal)") return @@ -118,7 +118,7 @@ class RootTests: XCTestCase { XCTAssertNil(showEffect) // Ensure the license info modal is visible. - let secondViewModel = root.viewModel(for: [], at: displayTime) + let (secondViewModel, _) = root.viewModel(for: [], at: displayTime) switch secondViewModel.modal { case .info(_, .some(let infoViewModel)): XCTAssert(infoViewModel.title == "Acknowledgements") @@ -138,7 +138,7 @@ class RootTests: XCTestCase { XCTAssertNil(hideEffect) // Ensure the license info modal no longer visible. - let thirdViewModel = root.viewModel(for: [], at: displayTime) + let (thirdViewModel, _) = root.viewModel(for: [], at: displayTime) guard case .none = thirdViewModel.modal else { XCTFail("Expected .none, got \(thirdViewModel.modal)") return @@ -194,7 +194,7 @@ class RootTests: XCTestCase { var root = Root(deviceCanScan: false) // Ensure the initial view model has no modal. - guard case .none = root.viewModel(for: [], at: displayTime).modal else { + guard case .none = root.viewModel(for: [], at: displayTime).viewModel.modal else { XCTFail("The initial view model should have no modal.") return } @@ -208,7 +208,7 @@ class RootTests: XCTestCase { } // Ensure the view model now has a modal entry form. - guard case .entryForm = root.viewModel(for: [], at: displayTime).modal else { + guard case .entryForm = root.viewModel(for: [], at: displayTime).viewModel.modal else { XCTFail("The view model should have a modal entry form.") return } @@ -218,7 +218,7 @@ class RootTests: XCTestCase { XCTAssertNil(effect) // Ensure the token entry form hides on success. - guard case .none = root.viewModel(for: [], at: displayTime).modal else { + guard case .none = root.viewModel(for: [], at: displayTime).viewModel.modal else { XCTFail("The final view model should have no modal.") return } diff --git a/AuthenticatorTests/TokenListTests.swift b/AuthenticatorTests/TokenListTests.swift index 976a34ac..27de3e5c 100644 --- a/AuthenticatorTests/TokenListTests.swift +++ b/AuthenticatorTests/TokenListTests.swift @@ -39,7 +39,7 @@ class TokenListTests: XCTestCase { ]) let effect = tokenList.update(.filter("goo")) - let viewModel = tokenList.viewModel(for: persistentTokens, at: displayTime) + let (viewModel, _) = tokenList.viewModel(for: persistentTokens, at: displayTime) let filteredIssuers = viewModel.rowModels.map { $0.issuer } XCTAssertNil(effect) @@ -56,7 +56,7 @@ class TokenListTests: XCTestCase { ("Service", "username"), ]) let effect = tokenList.update(.filter("Service")) - let viewModel = tokenList.viewModel(for: persistentTokens, at: displayTime) + let (viewModel, _) = tokenList.viewModel(for: persistentTokens, at: displayTime) XCTAssertNil(effect) XCTAssertTrue(viewModel.isFiltering) diff --git a/AuthenticatorTests/TokenListViewControllerTest.swift b/AuthenticatorTests/TokenListViewControllerTest.swift index f61e5a7c..790d3941 100644 --- a/AuthenticatorTests/TokenListViewControllerTest.swift +++ b/AuthenticatorTests/TokenListViewControllerTest.swift @@ -38,7 +38,7 @@ class TokenListViewControllerTest: XCTestCase { }() func emptyListViewModel() -> TokenList.ViewModel { - return tokenList.viewModel(for: [], at: displayTime) + return tokenList.viewModel(for: [], at: displayTime).viewModel } // Test that inserting a new token will produce the expected changes to the table view. @@ -53,7 +53,7 @@ class TokenListViewControllerTest: XCTestCase { let persistentTokens = mockPersistentTokens([ ("Service", "email@example.com"), ]) - let updatedViewModel = tokenList.viewModel(for: persistentTokens, at: displayTime) + let (updatedViewModel, _) = tokenList.viewModel(for: persistentTokens, at: displayTime) controller.updateWithViewModel(updatedViewModel) // Check the table view. @@ -70,7 +70,7 @@ class TokenListViewControllerTest: XCTestCase { func testUpdatesExistingToken() { // Set up a view controller with a mock table view. let initialPersistentToken = mockPersistentToken(name: "account@example.com", issuer: "Issuer") - let initialTokenListViewModel = tokenList.viewModel(for: [initialPersistentToken], at: displayTime) + let (initialTokenListViewModel, _) = tokenList.viewModel(for: [initialPersistentToken], at: displayTime) let controller = TokenListViewController(viewModel: initialTokenListViewModel, dispatchAction: { _ in }) let tableView = MockTableView() controller.tableView = tableView @@ -80,7 +80,7 @@ class TokenListViewControllerTest: XCTestCase { // Update the view controller. let updatedPersistentToken = initialPersistentToken.updated(with: mockToken(name: "name", issuer: "issuer")) - let updatedTokenListViewModel = tokenList.viewModel(for: [updatedPersistentToken], at: displayTime) + let (updatedTokenListViewModel, _) = tokenList.viewModel(for: [updatedPersistentToken], at: displayTime) controller.updateWithViewModel(updatedTokenListViewModel) // Check the changes to the table view. @@ -102,7 +102,7 @@ class TokenListViewControllerTest: XCTestCase { ("Service", "example@google.com"), ("Service", "username"), ]) - let viewModel = tokenList.viewModel(for: persistentTokens, at: displayTime) + let (viewModel, _) = tokenList.viewModel(for: persistentTokens, at: displayTime) let controller = TokenListViewController(viewModel: viewModel, dispatchAction: { _ in }) // Check that the table view contains the expected cells. diff --git a/Cartfile.private b/Cartfile.private index cb2073d6..fe6f0dfa 100644 --- a/Cartfile.private +++ b/Cartfile.private @@ -1,4 +1,4 @@ # Configuration for Carthage (https://github.com/Carthage/Carthage) -github "jspahrsummers/xcconfigs" ~> 0.10 +github "jspahrsummers/xcconfigs" ~> 0.11 github "shinydevelopment/SimulatorStatusMagic" ~> 2.0 diff --git a/Cartfile.resolved b/Cartfile.resolved index fb4caa69..38229d09 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -1,5 +1,5 @@ -github "SVProgressHUD/SVProgressHUD" "2.1.2" -github "jspahrsummers/xcconfigs" "0.10" +github "SVProgressHUD/SVProgressHUD" "2.2.2" +github "jspahrsummers/xcconfigs" "0.11" github "mattrubin/Base32" "1.1.2+carthage" github "mattrubin/OneTimePassword" "3.0" github "shinydevelopment/SimulatorStatusMagic" "2.0" diff --git a/Carthage/Checkouts/SVProgressHUD b/Carthage/Checkouts/SVProgressHUD index 9771120c..bb4e4e46 160000 --- a/Carthage/Checkouts/SVProgressHUD +++ b/Carthage/Checkouts/SVProgressHUD @@ -1 +1 @@ -Subproject commit 9771120c81550d951fe39be2e8831b81da79fec7 +Subproject commit bb4e4e460d4f8e2ecf20af99797f781e7cc09d8f diff --git a/Carthage/Checkouts/xcconfigs b/Carthage/Checkouts/xcconfigs index cc451b08..40f9bcc6 160000 --- a/Carthage/Checkouts/xcconfigs +++ b/Carthage/Checkouts/xcconfigs @@ -1 +1 @@ -Subproject commit cc451b08e052b6146f5caf66bc1120420c529c7b +Subproject commit 40f9bcc63752cdd95deee267d2fbf9da09a9f6f2 diff --git a/Gemfile.lock b/Gemfile.lock index 4db881ba..8510d733 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (2.3.5) + CFPropertyList (2.3.6) addressable (2.5.2) public_suffix (>= 2.0.2, < 4.0) babosa (1.0.2) @@ -24,7 +24,7 @@ GEM faraday_middleware (0.12.2) faraday (>= 0.7.4, < 1.0) fastimage (2.1.0) - fastlane (2.64.0) + fastlane (2.68.2) CFPropertyList (>= 2.3, < 3.0.0) addressable (>= 2.3, < 3.0.0) babosa (>= 1.0.2, < 2.0.0) @@ -52,9 +52,9 @@ GEM slack-notifier (>= 1.3, < 2.0.0) terminal-notifier (>= 1.6.2, < 2.0.0) terminal-table (>= 1.4.5, < 2.0.0) - tty-screen (~> 0.5.0) + tty-screen (~> 0.6.2) word_wrap (~> 1.0.0) - xcodeproj (>= 1.5.0, < 2.0.0) + xcodeproj (>= 1.5.2, < 2.0.0) xcpretty (>= 0.2.4, < 1.0.0) xcpretty-travis-formatter (>= 0.0.3) gh_inspector (1.0.3) @@ -65,7 +65,7 @@ GEM mime-types (~> 3.0) representable (~> 3.0) retriable (>= 2.0, < 4.0) - googleauth (0.6.1) + googleauth (0.6.2) faraday (~> 0.12) jwt (>= 1.4, < 3.0) logging (~> 2.0) @@ -73,7 +73,7 @@ GEM multi_json (~> 1.11) os (~> 0.9) signet (~> 0.7) - highline (1.7.8) + highline (1.7.10) http-cookie (1.0.3) domain_name (~> 0.5) httpclient (2.8.3) @@ -93,7 +93,7 @@ GEM multipart-post (2.0.0) nanaimo (0.2.3) os (0.9.6) - plist (3.3.0) + plist (3.4.0) public_suffix (2.0.5) representable (3.0.4) declarative (< 0.1.0) @@ -112,7 +112,7 @@ GEM terminal-notifier (1.8.0) terminal-table (1.8.0) unicode-display_width (~> 1.1, >= 1.1.1) - tty-screen (0.5.1) + tty-screen (0.6.3) uber (0.1.0) unf (0.1.4) unf_ext diff --git a/README.md b/README.md index 92c11f03..21e5912b 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ Authenticator is a simple, free, and open source [two-factor authentication](htt - Compatible: Full support for [time-based](https://tools.ietf.org/html/rfc6238) and [counter-based](https://tools.ietf.org/html/rfc4226) one-time passwords as standardized in RFC 4226 and 6238 - Off the Grid: The app never connects to the internet, and your secret keys never leave your device. -Screenshot of the Authenticator token list   -Screenshot of the Authenticator QR Code scanner   -Screenshot of the Authenticator token entry form +Screenshot of the Authenticator token list   +Screenshot of the Authenticator QR Code scanner   +Screenshot of the Authenticator token entry form ## Getting Started diff --git a/fastlane/metadata/en-US/release_notes.txt b/fastlane/metadata/en-US/release_notes.txt index ea69d4a6..5cb10c64 100644 --- a/fastlane/metadata/en-US/release_notes.txt +++ b/fastlane/metadata/en-US/release_notes.txt @@ -1,3 +1,2 @@ -• Added support for iPhone X -• Fixed a bug where tokens were sometimes copied when trying to scroll the token list -• Fixed button text color and font weight on iOS 11 +• Improved the accessibility of manual token entry when using VoiceOver +• Improved app efficiency, reducing energy usage and processor load by over 95% diff --git a/fastlane/screenshots/en-US/iPhone 8 Plus-0-TokenList.png b/fastlane/screenshots/en-US/iPhone 8 Plus-0-TokenList.png index 85dae059..f4ee6d63 100644 Binary files a/fastlane/screenshots/en-US/iPhone 8 Plus-0-TokenList.png and b/fastlane/screenshots/en-US/iPhone 8 Plus-0-TokenList.png differ diff --git a/fastlane/screenshots/en-US/iPhone 8-0-TokenList.png b/fastlane/screenshots/en-US/iPhone 8-0-TokenList.png index b1a0ff2f..0f7f6d5c 100644 Binary files a/fastlane/screenshots/en-US/iPhone 8-0-TokenList.png and b/fastlane/screenshots/en-US/iPhone 8-0-TokenList.png differ diff --git a/fastlane/screenshots/en-US/iPhone SE-0-TokenList.png b/fastlane/screenshots/en-US/iPhone SE-0-TokenList.png index 092753f6..681352b6 100644 Binary files a/fastlane/screenshots/en-US/iPhone SE-0-TokenList.png and b/fastlane/screenshots/en-US/iPhone SE-0-TokenList.png differ diff --git a/fastlane/screenshots/en-US/iPhone X-0-TokenList.png b/fastlane/screenshots/en-US/iPhone X-0-TokenList.png index 49d6e0c6..16388310 100644 Binary files a/fastlane/screenshots/en-US/iPhone X-0-TokenList.png and b/fastlane/screenshots/en-US/iPhone X-0-TokenList.png differ