diff --git a/Loop/Managers/KeychainManager+Loop.swift b/Loop/Managers/KeychainManager+Loop.swift index e8200283b5..d54a562c77 100644 --- a/Loop/Managers/KeychainManager+Loop.swift +++ b/Loop/Managers/KeychainManager+Loop.swift @@ -9,33 +9,10 @@ import Foundation -private let DexcomShareURL = URL(string: "https://share1.dexcom.com")! private let NightscoutAccount = "NightscoutAPI" extension KeychainManager { - func setDexcomShareUsername(_ username: String?, password: String?) throws { - let credentials: InternetCredentials? - - if let username = username, let password = password { - credentials = InternetCredentials(username: username, password: password, url: DexcomShareURL) - } else { - credentials = nil - } - - try replaceInternetCredentials(credentials, forURL: DexcomShareURL) - } - - func getDexcomShareCredentials() -> (username: String, password: String)? { - do { - let credentials = try getInternetCredentials(url: DexcomShareURL) - - return (username: credentials.username, password: credentials.password) - } catch { - return nil - } - } - func setNightscoutURL(_ url: URL?, secret: String?) { let credentials: InternetCredentials? diff --git a/Loop/Managers/KeychainManager.swift b/Loop/Managers/KeychainManager.swift index ff0e0db0d6..32258252f7 100644 --- a/Loop/Managers/KeychainManager.swift +++ b/Loop/Managers/KeychainManager.swift @@ -55,7 +55,7 @@ struct KeychainManager { return query } - private func queryForInternetPassword(account: String? = nil, url: URL? = nil) -> Query { + private func queryForInternetPassword(account: String? = nil, url: URL? = nil, label: String? = nil) -> Query { var query = self.query(by: kSecClassInternetPassword) if let account = account { @@ -68,6 +68,10 @@ struct KeychainManager { } } + if let label = label { + query[kSecAttrLabel as String] = label as NSObject? + } + return query } @@ -135,8 +139,8 @@ struct KeychainManager { // MARK – Internet Passwords - func setInternetPassword(_ password: String, forAccount account: String, atURL url: URL) throws { - var query = try updatedQuery(queryForInternetPassword(account: account, url: url), withPassword: password) + func setInternetPassword(_ password: String, account: String, atURL url: URL, label: String? = nil) throws { + var query = try updatedQuery(queryForInternetPassword(account: account, url: url, label: label), withPassword: password) query[kSecAttrAccount as String] = account as NSObject? @@ -146,6 +150,10 @@ struct KeychainManager { } } + if let label = label { + query[kSecAttrLabel as String] = label as NSObject? + } + let statusCode = SecItemAdd(query as CFDictionary, nil) guard statusCode == errSecSuccess else { @@ -159,7 +167,17 @@ struct KeychainManager { try delete(query) if let credentials = credentials { - try setInternetPassword(credentials.password, forAccount: credentials.username, atURL: credentials.url) + try setInternetPassword(credentials.password, account: credentials.username, atURL: credentials.url) + } + } + + func replaceInternetCredentials(_ credentials: InternetCredentials?, forLabel label: String) throws { + let query = queryForInternetPassword(label: label) + + try delete(query) + + if let credentials = credentials { + try setInternetPassword(credentials.password, account: credentials.username, atURL: credentials.url, label: label) } } @@ -169,12 +187,12 @@ struct KeychainManager { try delete(query) if let credentials = credentials { - try setInternetPassword(credentials.password, forAccount: credentials.username, atURL: credentials.url) + try setInternetPassword(credentials.password, account: credentials.username, atURL: credentials.url) } } - func getInternetCredentials(account: String? = nil, url: URL? = nil) throws -> InternetCredentials { - var query = queryForInternetPassword(account: account, url: url) + func getInternetCredentials(account: String? = nil, url: URL? = nil, label: String? = nil) throws -> InternetCredentials { + var query = queryForInternetPassword(account: account, url: url, label: label) query[kSecReturnData as String] = kCFBooleanTrue query[kSecReturnAttributes as String] = kCFBooleanTrue diff --git a/Loop/Managers/RemoteDataManager.swift b/Loop/Managers/RemoteDataManager.swift index d30c539656..9c1eb6deb2 100644 --- a/Loop/Managers/RemoteDataManager.swift +++ b/Loop/Managers/RemoteDataManager.swift @@ -25,17 +25,17 @@ final class RemoteDataManager { var shareService: ShareService { didSet { - try! keychain.setDexcomShareUsername(shareService.username, password: shareService.password) + try! keychain.setDexcomShareUsername(shareService.username, password: shareService.password, url: shareService.url) } } private let keychain = KeychainManager() init() { - if let (username, password) = keychain.getDexcomShareCredentials() { - shareService = ShareService(username: username, password: password) + if let (username, password, url) = keychain.getDexcomShareCredentials() { + shareService = ShareService(username: username, password: password, url: url) } else { - shareService = ShareService(username: nil, password: nil) + shareService = ShareService(username: nil, password: nil, url: nil) } if let (siteURL, APISecret) = keychain.getNightscoutCredentials() { diff --git a/Loop/Models/ServiceAuthentication/ServiceCredential.swift b/Loop/Models/ServiceAuthentication/ServiceCredential.swift index 2556617541..211a4c60b6 100644 --- a/Loop/Models/ServiceAuthentication/ServiceCredential.swift +++ b/Loop/Models/ServiceAuthentication/ServiceCredential.swift @@ -9,20 +9,32 @@ import UIKit -// Represents a credential for a service, including its text input traits +/// Represents a credential for a service, including its text input traits struct ServiceCredential { - // The localized title of the credential (e.g. "Username") + /// The localized title of the credential (e.g. "Username") let title: String - // The localized placeholder text to assist text input + /// The localized placeholder text to assist text input let placeholder: String? - // Whether the credential is considered secret. Correponds to the `secureTextEntry` trait. + /// Whether the credential is considered secret. Correponds to the `secureTextEntry` trait. let isSecret: Bool - // The type of keyboard to use to enter the credential + /// The type of keyboard to use to enter the credential let keyboardType: UIKeyboardType - // The credential value + /// The credential value var value: String? + + /// A set of valid values for presenting a selection. The first item is the default. + var options: [(title: String, value: String)]? + + init(title: String, placeholder: String? = nil, isSecret: Bool, keyboardType: UIKeyboardType = .asciiCapable, value: String?, options: [(title: String, value: String)]? = nil) { + self.title = title + self.placeholder = placeholder + self.isSecret = isSecret + self.keyboardType = keyboardType + self.value = value + self.options = options + } } diff --git a/Loop/Models/ServiceAuthentication/ShareService.swift b/Loop/Models/ServiceAuthentication/ShareService.swift index 0c0f423aef..90a37000da 100644 --- a/Loop/Models/ServiceAuthentication/ShareService.swift +++ b/Loop/Models/ServiceAuthentication/ShareService.swift @@ -16,25 +16,32 @@ class ShareService: ServiceAuthentication { let title: String = NSLocalizedString("Dexcom Share", comment: "The title of the Dexcom Share service") - init(username: String?, password: String?) { + init(username: String?, password: String?, url: URL?) { credentials = [ ServiceCredential( title: NSLocalizedString("Username", comment: "The title of the Dexcom share username credential"), - placeholder: nil, isSecret: false, keyboardType: .asciiCapable, value: username ), ServiceCredential( title: NSLocalizedString("Password", comment: "The title of the Dexcom share password credential"), - placeholder: nil, isSecret: true, keyboardType: .asciiCapable, value: password + ), + ServiceCredential( + title: NSLocalizedString("Server", comment: "The title of the Dexcom share server URL credential"), + isSecret: false, + value: url?.absoluteString, + options: [ + (title: NSLocalizedString("US", comment: "U.S. share server option title"), + value: DexcomShareURL.absoluteString) + ] ) ] - if let username = username, let password = password { + if let username = username, let password = password, url != nil { isAuthorized = true client = ShareClient(username: username, password: password) } @@ -51,10 +58,18 @@ class ShareService: ServiceAuthentication { return credentials[1].value } + var url: URL? { + guard let urlString = credentials[2].value else { + return nil + } + + return URL(string: urlString) + } + var isAuthorized: Bool = false func verify(_ completion: @escaping (_ success: Bool, _ error: Error?) -> Void) { - guard let username = username, let password = password else { + guard let username = username, let password = password, url != nil else { completion(false, nil) return } @@ -69,7 +84,49 @@ class ShareService: ServiceAuthentication { func reset() { credentials[0].value = nil credentials[1].value = nil + credentials[2].value = nil isAuthorized = false client = nil } } + + +private let DexcomShareURL = URL(string: "https://share1.dexcom.com")! +private let DexcomShareServiceLabel = "DexcomShare1" + + +extension KeychainManager { + func setDexcomShareUsername(_ username: String?, password: String?, url: URL?) throws { + let credentials: InternetCredentials? + + if let username = username, let password = password, let url = url { + credentials = InternetCredentials(username: username, password: password, url: url) + } else { + credentials = nil + } + + // Replace the legacy URL-keyed credentials + try replaceInternetCredentials(nil, forURL: DexcomShareURL) + + try replaceInternetCredentials(credentials, forLabel: DexcomShareServiceLabel) + } + + func getDexcomShareCredentials() -> (username: String, password: String, url: URL)? { + do { // Silence all errors and return nil + do { + let credentials = try getInternetCredentials(label: DexcomShareServiceLabel) + + return (username: credentials.username, password: credentials.password, url: credentials.url) + } catch KeychainManagerError.copy { + // Fetch and replace the legacy URL-keyed credentials + let credentials = try getInternetCredentials(url: DexcomShareURL) + + try setDexcomShareUsername(credentials.username, password: credentials.password, url: credentials.url) + + return (username: credentials.username, password: credentials.password, url: credentials.url) + } + } catch { + return nil + } + } +} diff --git a/Loop/View Controllers/AuthenticationViewController.swift b/Loop/View Controllers/AuthenticationViewController.swift index adc690fd72..3ebbc04508 100644 --- a/Loop/View Controllers/AuthenticationViewController.swift +++ b/Loop/View Controllers/AuthenticationViewController.swift @@ -59,6 +59,15 @@ final class AuthenticationViewController: UITableViewC } } + var credentials: [ServiceCredential] { + switch state { + case .authorized: + return authentication.credentials.filter({ !$0.isSecret }) + default: + return authentication.credentials + } + } + init(authentication: T) { self.authentication = authentication @@ -89,12 +98,7 @@ final class AuthenticationViewController: UITableViewC override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { switch Section(rawValue: section)! { case .credentials: - switch state { - case .authorized: - return authentication.credentials.filter({ !$0.isSecret }).count - default: - return authentication.credentials.count - } + return credentials.count case .button: return 1 } @@ -126,16 +130,23 @@ final class AuthenticationViewController: UITableViewC case .credentials: let cell = tableView.dequeueReusableCell(withIdentifier: AuthenticationTableViewCell.className, for: indexPath) as! AuthenticationTableViewCell - let credential = authentication.credentials[indexPath.row] + let credentials = self.credentials + let credential = credentials[indexPath.row] cell.titleLabel.text = credential.title - cell.textField.tag = indexPath.row cell.textField.keyboardType = credential.keyboardType cell.textField.isSecureTextEntry = credential.isSecret - cell.textField.returnKeyType = (indexPath.row < authentication.credentials.count - 1) ? .next : .done + cell.textField.returnKeyType = (indexPath.row < credentials.count - 1) ? .next : .done cell.textField.text = credential.value cell.textField.placeholder = credential.placeholder ?? NSLocalizedString("Required", comment: "The default placeholder string for a credential") + if let options = credential.options { + let picker = CredentialOptionPicker(options: options) + picker.value = credential.value + + cell.credentialOptionPicker = picker + } + cell.textField.delegate = self switch state { @@ -149,7 +160,7 @@ final class AuthenticationViewController: UITableViewC } } - private func validate() { + fileprivate func validate() { state = .verifying } @@ -173,7 +184,16 @@ final class AuthenticationViewController: UITableViewC // MARK: - UITextFieldDelegate func textFieldDidEndEditing(_ textField: UITextField) { - authentication.credentials[textField.tag].value = textField.text + let point = tableView.convert(textField.frame.origin, from: textField.superview) + + guard case .unauthorized = state, + let indexPath = tableView.indexPathForRow(at: point), + let cell = tableView.cellForRow(at: IndexPath(row: indexPath.row, section: indexPath.section)) as? AuthenticationTableViewCell + else { + return + } + + authentication.credentials[indexPath.row].value = cell.value } func textFieldShouldReturn(_ textField: UITextField) -> Bool { @@ -191,6 +211,10 @@ final class AuthenticationViewController: UITableViewC return true } + + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + return textField.inputView == nil + } } diff --git a/Loop/Views/AuthenticationTableViewCell.swift b/Loop/Views/AuthenticationTableViewCell.swift index 7d8eb85504..d44a0406a6 100644 --- a/Loop/Views/AuthenticationTableViewCell.swift +++ b/Loop/Views/AuthenticationTableViewCell.swift @@ -8,6 +8,7 @@ import UIKit + final class AuthenticationTableViewCell: UITableViewCell, NibLoadable { @IBOutlet weak var titleLabel: UILabel! @@ -28,4 +29,104 @@ final class AuthenticationTableViewCell: UITableViewCell, NibLoadable { textField.delegate = nil } + var credentialOptionPicker: CredentialOptionPicker? { + didSet { + if let picker = credentialOptionPicker { + picker.delegate = self + + textField.text = picker.selectedOption.title + textField.inputView = picker.view + textField.tintColor = .clear // Makes the cursor invisible + } else { + textField.inputView = nil + textField.tintColor = nil + } + } + } + + var value: String? { + if let picker = credentialOptionPicker { + return picker.value + } else { + return textField.text + } + } +} + +extension AuthenticationTableViewCell: CredentialOptionPickerDelegate { + func credentialOptionDataSourceDidUpdateValue(_ picker: CredentialOptionPicker) { + textField.text = picker.selectedOption.title + textField.delegate?.textFieldDidEndEditing?(textField) + } +} + + +protocol CredentialOptionPickerDelegate: class { + func credentialOptionDataSourceDidUpdateValue(_ picker: CredentialOptionPicker) +} + + +class CredentialOptionPicker: NSObject, UIPickerViewDataSource, UIPickerViewDelegate { + let options: [(title: String, value: String)] + + weak var delegate: CredentialOptionPickerDelegate? + + let view = UIPickerView() + + var selectedOption: (title: String, value: String) { + let index = view.selectedRow(inComponent: 0) + guard index >= options.startIndex && index < options.endIndex else { + return options[0] + } + + return options[index] + } + + var value: String? { + get { + return selectedOption.value + } + set { + let index: Int + + if let value = newValue, let optionIndex = options.index(where: { $0.value == value }) { + index = optionIndex + } else { + index = 0 + } + + view.selectRow(index, inComponent: 0, animated: view.superview != nil) + } + } + + init(options: [(title: String, value: String)]) { + assert(options.count > 0, "At least one option must be specified") + + self.options = options + + super.init() + + view.dataSource = self + view.delegate = self + } + + // MARK: - UIPickerViewDataSource + + func numberOfComponents(in pickerView: UIPickerView) -> Int { + return 1 + } + + func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { + return options.count + } + + // MARK: - UIPickerViewDelegate + + func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { + return options[row].title + } + + func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { + delegate?.credentialOptionDataSourceDidUpdateValue(self) + } } diff --git a/LoopTests/KeychainManagerTests.swift b/LoopTests/KeychainManagerTests.swift index 5035c399f3..4195fae0bf 100644 --- a/LoopTests/KeychainManagerTests.swift +++ b/LoopTests/KeychainManagerTests.swift @@ -15,10 +15,10 @@ class KeychainManagerTests: XCTestCase { func testInvalidData() throws { let manager = KeychainManager() - try manager.setDexcomShareUsername(nil, password: "foo") + try manager.setDexcomShareUsername(nil, password: "foo", url: URL(string: "https://share1.dexcom.com")!) XCTAssertNil(manager.getDexcomShareCredentials()) - try manager.setDexcomShareUsername("foo", password: nil) + try manager.setDexcomShareUsername("foo", password: nil, url: URL(string: "https://share1.dexcom.com")!) XCTAssertNil(manager.getDexcomShareCredentials()) manager.setNightscoutURL(nil, secret: "foo") @@ -37,12 +37,13 @@ class KeychainManagerTests: XCTestCase { try manager.setAmplitudeAPIKey(nil) XCTAssertNil(manager.getAmplitudeAPIKey()) - try manager.setDexcomShareUsername("sugarman", password: "rodriguez") + try manager.setDexcomShareUsername("sugarman", password: "rodriguez", url: URL(string: "https://share1.dexcom.com")!) let dexcomCredentials = manager.getDexcomShareCredentials()! XCTAssertEqual("sugarman", dexcomCredentials.username) XCTAssertEqual("rodriguez", dexcomCredentials.password) + XCTAssertEqual("https://share1.dexcom.com", dexcomCredentials.url.absoluteString) - try manager.setDexcomShareUsername(nil, password: nil) + try manager.setDexcomShareUsername(nil, password: nil, url: nil) XCTAssertNil(manager.getDexcomShareCredentials()) manager.setNightscoutURL(URL(string: "http://mysite.azurewebsites.net")!, secret: "ABCDEFG")