Skip to content

Commit aae066c

Browse files
authored
Merge pull request #194 from mattrubin/camera-permissions
Handle missing camera permissions
2 parents 7d45641 + af3f813 commit aae066c

File tree

6 files changed

+113
-6
lines changed

6 files changed

+113
-6
lines changed

Authenticator/Source/AppController.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,13 @@ class AppController {
182182
feedbackGenerator.notificationOccurred(.success)
183183
}
184184

185+
case .showApplicationSettings:
186+
guard let applicationSettingsURL = URL(string: UIApplicationOpenSettingsURLString) else {
187+
handleEffect(.showErrorMessage("Failed to open application settings."))
188+
return
189+
}
190+
UIApplication.shared.openURL(applicationSettingsURL)
191+
185192
case let .openURL(url):
186193
if #available(iOS 9.0, *) {
187194
let safariViewController = SFSafariViewController(url: url)

Authenticator/Source/QRScanner.swift

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,25 +62,25 @@ class QRScanner: NSObject, AVCaptureMetadataOutputObjectsDelegate {
6262
// MARK: Capture
6363

6464
enum CaptureSessionError: Error {
65-
case inputError
66-
case outputError
65+
case noCaptureDevice
66+
case noQRCodeMetadataType
6767
}
6868

6969
private class func createCaptureSessionWithDelegate(_ delegate: AVCaptureMetadataOutputObjectsDelegate) throws -> AVCaptureSession {
7070
let captureSession = AVCaptureSession()
7171

72-
guard let captureDevice = AVCaptureDevice.defaultDevice(withMediaType: AVMediaTypeVideo),
73-
let captureInput = try? AVCaptureDeviceInput(device: captureDevice) else {
74-
throw CaptureSessionError.inputError
72+
guard let captureDevice = AVCaptureDevice.defaultDevice(withMediaType: AVMediaTypeVideo) else {
73+
throw CaptureSessionError.noCaptureDevice
7574
}
75+
let captureInput = try AVCaptureDeviceInput(device: captureDevice)
7676
captureSession.addInput(captureInput)
7777

7878
let captureOutput = AVCaptureMetadataOutput()
7979
// The output must be added to the session before it can be checked for metadata types
8080
captureSession.addOutput(captureOutput)
8181
guard let availableTypes = captureOutput.availableMetadataObjectTypes,
8282
(availableTypes as NSArray).contains(AVMetadataObjectTypeQRCode) else {
83-
throw CaptureSessionError.outputError
83+
throw CaptureSessionError.noQRCodeMetadataType
8484
}
8585
captureOutput.metadataObjectTypes = [AVMetadataObjectTypeQRCode]
8686
captureOutput.setMetadataObjectsDelegate(delegate, queue: DispatchQueue.main)
@@ -92,6 +92,14 @@ class QRScanner: NSObject, AVCaptureMetadataOutputObjectsDelegate {
9292
return (AVCaptureDevice.defaultDevice(withMediaType: AVMediaTypeVideo) != nil)
9393
}
9494

95+
class var authorizationStatus: AVAuthorizationStatus {
96+
return AVCaptureDevice.authorizationStatus(forMediaType: AVMediaTypeVideo)
97+
}
98+
99+
class func requestAccess(_ completionHandler: @escaping (Bool) -> Void) {
100+
AVCaptureDevice.requestAccess(forMediaType: AVMediaTypeVideo, completionHandler: completionHandler)
101+
}
102+
95103
// MARK: AVCaptureMetadataOutputObjectsDelegate
96104

97105
func captureOutput(_ captureOutput: AVCaptureOutput?, didOutputMetadataObjects metadataObjects: [Any]?, from connection: AVCaptureConnection?) {

Authenticator/Source/Root.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ extension Root {
119119

120120
case showErrorMessage(String)
121121
case showSuccessMessage(String)
122+
case showApplicationSettings
122123
case openURL(URL)
123124
}
124125

@@ -283,6 +284,9 @@ extension Root {
283284
modal = .entryForm(TokenEntryForm())
284285
return nil
285286

287+
case .showApplicationSettings:
288+
return .showApplicationSettings
289+
286290
case .saveNewToken(let token):
287291
return .addToken(token,
288292
success: Event.tokenFormSucceeded,

Authenticator/Source/TokenScanner.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,15 @@ struct TokenScanner: Component {
5050
enum Action {
5151
case cancel
5252
case beginManualTokenEntry
53+
case showApplicationSettings
5354
case scannerDecodedText(String)
5455
case scannerError(Error)
5556
}
5657

5758
enum Effect {
5859
case cancel
5960
case beginManualTokenEntry
61+
case showApplicationSettings
6062
case saveNewToken(Token)
6163
case showErrorMessage(String)
6264
}
@@ -69,6 +71,9 @@ struct TokenScanner: Component {
6971
case .beginManualTokenEntry:
7072
return .beginManualTokenEntry
7173

74+
case .showApplicationSettings:
75+
return .showApplicationSettings
76+
7277
case .scannerDecodedText(let text):
7378
// Attempt to create a token from the decoded text
7479
guard let url = URL(string: text),

Authenticator/Source/TokenScannerViewController.swift

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,39 @@ class TokenScannerViewController: UIViewController, QRScannerDelegate {
3434
private var viewModel: TokenScanner.ViewModel
3535
private let dispatchAction: (TokenScanner.Action) -> Void
3636

37+
private let permissionLabel: UILabel = {
38+
let linkTitle = "Go to Settings →"
39+
let message = "To add a new token via QR code, Authenticator needs permission to access the camera.\n\(linkTitle)"
40+
let paragraphStyle = NSMutableParagraphStyle()
41+
paragraphStyle.lineHeightMultiple = 1.3
42+
paragraphStyle.paragraphSpacing = 5
43+
let attributedMessage = NSMutableAttributedString(string: message, attributes: [
44+
NSFontAttributeName: UIFont.systemFont(ofSize: 15, weight: UIFontWeightLight),
45+
NSParagraphStyleAttributeName: paragraphStyle,
46+
])
47+
attributedMessage.addAttribute(NSFontAttributeName, value: UIFont.boldSystemFont(ofSize: 15),
48+
range: (attributedMessage.string as NSString).range(of: linkTitle))
49+
50+
let label = UILabel()
51+
label.numberOfLines = 0
52+
label.attributedText = attributedMessage
53+
label.textAlignment = .center
54+
label.textColor = UIColor.otpForegroundColor
55+
return label
56+
}()
57+
58+
private lazy var permissionButton: UIButton = {
59+
let button = UIButton(frame: UIScreen.main.bounds)
60+
button.backgroundColor = .black
61+
button.addTarget(self, action: #selector(TokenScannerViewController.editPermissions), for: .touchUpInside)
62+
63+
self.permissionLabel.frame = button.bounds.insetBy(dx: 35, dy: 35)
64+
self.permissionLabel.autoresizingMask = [.flexibleWidth, .flexibleHeight]
65+
button.addSubview(self.permissionLabel)
66+
67+
return button
68+
}()
69+
3770
// MARK: Initialization
3871

3972
init(viewModel: TokenScanner.ViewModel, dispatchAction: @escaping (TokenScanner.Action) -> Void) {
@@ -73,6 +106,11 @@ class TokenScannerViewController: UIViewController, QRScannerDelegate {
73106
videoLayer.frame = view.layer.bounds
74107
view.layer.addSublayer(videoLayer)
75108

109+
permissionButton.frame = view.bounds
110+
permissionButton.autoresizingMask = [.flexibleWidth, .flexibleHeight]
111+
permissionButton.isHidden = true
112+
view.addSubview(permissionButton)
113+
76114
if CommandLine.isDemo {
77115
// If this is a demo, display an image in place of the AVCaptureVideoPreviewLayer.
78116
let imageView = UIImageView(frame: view.bounds)
@@ -83,17 +121,46 @@ class TokenScannerViewController: UIViewController, QRScannerDelegate {
83121
}
84122

85123
let overlayView = ScannerOverlayView(frame: view.bounds)
124+
overlayView.isUserInteractionEnabled = false
86125
view.addSubview(overlayView)
87126
}
88127

89128
override func viewWillAppear(_ animated: Bool) {
90129
super.viewWillAppear(animated)
130+
131+
switch QRScanner.authorizationStatus {
132+
case .notDetermined:
133+
QRScanner.requestAccess { [weak self] accessGranted in
134+
if accessGranted {
135+
self?.startScanning()
136+
} else {
137+
self?.showMissingAccessMessage()
138+
}
139+
}
140+
case .authorized:
141+
startScanning()
142+
case .denied:
143+
showMissingAccessMessage()
144+
case .restricted:
145+
// There's nothing we can do if camera access is restricted.
146+
// This should only ever be reached if camera restrictions are changed while the app is running, because if
147+
// the app is launched with restrictions already enabled, the deviceCanScan check will retrun false.
148+
dispatchAction(.beginManualTokenEntry)
149+
break
150+
}
151+
}
152+
153+
private func startScanning() {
91154
scanner.delegate = self
92155
scanner.start { captureSession in
93156
self.videoLayer.session = captureSession
94157
}
95158
}
96159

160+
private func showMissingAccessMessage() {
161+
permissionButton.isHidden = false
162+
}
163+
97164
override func viewWillDisappear(_ animated: Bool) {
98165
super.viewWillDisappear(animated)
99166
scanner.stop()
@@ -109,6 +176,10 @@ class TokenScannerViewController: UIViewController, QRScannerDelegate {
109176
dispatchAction(.beginManualTokenEntry)
110177
}
111178

179+
func editPermissions() {
180+
dispatchAction(.showApplicationSettings)
181+
}
182+
112183
// MARK: QRScannerDelegate
113184

114185
func handleDecodedText(_ text: String) {

AuthenticatorTests/TokenScannerTests.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,18 @@ class TokenScannerTests: XCTestCase {
5858
XCTAssertTrue(tokenScanner.viewModel.isScanning)
5959
}
6060

61+
func testShowApplicationSettings() {
62+
var tokenScanner = TokenScanner()
63+
64+
let action = TokenScanner.Action.showApplicationSettings
65+
let effect = tokenScanner.update(action)
66+
guard let requiredEffect = effect,
67+
case .showApplicationSettings = requiredEffect else {
68+
XCTFail("Expected effect .showApplicationSettings, got \(String(describing: effect))")
69+
return
70+
}
71+
}
72+
6173
func testScannerDecodedBadText() {
6274
var tokenScanner = TokenScanner()
6375
XCTAssertTrue(tokenScanner.viewModel.isScanning)

0 commit comments

Comments
 (0)