Sketch out CAPTCHA onboarding view.

pull/2/head
Matthew Chen 6 years ago
parent 9381220d8f
commit df12f71b74

@ -3,412 +3,208 @@
// //
import UIKit import UIKit
import PromiseKit import WebKit
@objc @objc
public class OnboardingCaptchaViewController: OnboardingBaseViewController { public class OnboardingCaptchaViewController: OnboardingBaseViewController {
// MARK: - Dependencies private var webView: WKWebView?
private var tsAccountManager: TSAccountManager {
return TSAccountManager.sharedInstance()
}
// MARK: -
private let countryNameLabel = UILabel()
private let callingCodeLabel = UILabel()
private let phoneNumberTextField = UITextField()
private var nextButton: OWSFlatButton?
override public func loadView() { override public func loadView() {
super.loadView() super.loadView()
populateDefaults()
view.backgroundColor = Theme.backgroundColor view.backgroundColor = Theme.backgroundColor
view.backgroundColor = .orange
view.layoutMargins = .zero view.layoutMargins = .zero
// TODO: // TODO:
// navigationItem.title = NSLocalizedString("SETTINGS_BACKUP", comment: "Label for the backup view in app settings.") // navigationItem.title = NSLocalizedString("SETTINGS_BACKUP", comment: "Label for the backup view in app settings.")
let titleLabel = self.titleLabel(text: NSLocalizedString("ONBOARDING_PHONE_NUMBER_TITLE", comment: "Title of the 'onboarding phone number' view.")) let titleLabel = self.titleLabel(text: NSLocalizedString("ONBOARDING_CAPTCHA_TITLE", comment: "Title of the 'onboarding Captcha' view."))
// Country
let rowHeight: CGFloat = 40 let titleRow = UIStackView(arrangedSubviews: [
titleLabel
countryNameLabel.textColor = Theme.primaryColor
countryNameLabel.font = UIFont.ows_dynamicTypeBody
countryNameLabel.setContentHuggingHorizontalLow()
countryNameLabel.setCompressionResistanceHorizontalLow()
let countryIcon = UIImage(named: (CurrentAppContext().isRTL
? "small_chevron_left"
: "small_chevron_right"))
let countryImageView = UIImageView(image: countryIcon?.withRenderingMode(.alwaysTemplate))
countryImageView.tintColor = Theme.placeholderColor
countryImageView.setContentHuggingHigh()
countryImageView.setCompressionResistanceHigh()
let countryRow = UIStackView(arrangedSubviews: [
countryNameLabel,
countryImageView
]) ])
countryRow.axis = .horizontal titleRow.axis = .vertical
countryRow.alignment = .center titleRow.alignment = .fill
countryRow.spacing = 10 titleRow.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 0, right: 16)
countryRow.isUserInteractionEnabled = true titleRow.isLayoutMarginsRelativeArrangement = true
countryRow.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(countryRowTapped)))
countryRow.autoSetDimension(.height, toSize: rowHeight) // We want the CAPTCHA web content to "fill the screen (honoring margins)".
addBottomStroke(countryRow) // The way to do this with WKWebView is to inject a javascript snippet that
// manipulates the viewport.
callingCodeLabel.textColor = Theme.primaryColor let jscript = "var meta = document.createElement('meta'); meta.setAttribute('name', 'viewport'); meta.setAttribute('content', 'width=device-width'); document.getElementsByTagName('head')[0].appendChild(meta);"
callingCodeLabel.font = UIFont.ows_dynamicTypeBody let userScript = WKUserScript(source: jscript, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
callingCodeLabel.setContentHuggingHorizontalHigh() let wkUController = WKUserContentController()
callingCodeLabel.setCompressionResistanceHorizontalHigh() wkUController.addUserScript(userScript)
callingCodeLabel.isUserInteractionEnabled = true let wkWebConfig = WKWebViewConfiguration()
callingCodeLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(countryCodeTapped))) wkWebConfig.userContentController = wkUController
addBottomStroke(callingCodeLabel) let webView = WKWebView(frame: self.view.bounds, configuration: wkWebConfig)
callingCodeLabel.autoSetDimension(.width, toSize: rowHeight, relation: .greaterThanOrEqual) self.webView = webView
webView.navigationDelegate = self
phoneNumberTextField.textAlignment = .left webView.allowsBackForwardNavigationGestures = false
phoneNumberTextField.delegate = self webView.customUserAgent = "Signal iOS (+https://signal.org/download)"
phoneNumberTextField.keyboardType = .numberPad webView.allowsLinkPreview = false
phoneNumberTextField.textColor = Theme.primaryColor // webView.scrollView.contentInset = .zero
phoneNumberTextField.font = UIFont.ows_dynamicTypeBody // webView.layoutMargins = .zero
phoneNumberTextField.setContentHuggingHorizontalLow()
phoneNumberTextField.setCompressionResistanceHorizontalLow()
addBottomStroke(phoneNumberTextField)
let phoneNumberRow = UIStackView(arrangedSubviews: [
callingCodeLabel,
phoneNumberTextField
])
phoneNumberRow.axis = .horizontal
phoneNumberRow.alignment = .fill
phoneNumberRow.spacing = 10
phoneNumberRow.autoSetDimension(.height, toSize: rowHeight)
callingCodeLabel.autoMatch(.height, to: .height, of: phoneNumberTextField)
// TODO: Finalize copy.
let nextButton = self.button(title: NSLocalizedString("BUTTON_NEXT",
comment: "Label for the 'next' button."),
selector: #selector(nextPressed))
self.nextButton = nextButton
let topSpacer = UIView.vStretchingSpacer()
let bottomSpacer = UIView.vStretchingSpacer()
let stackView = UIStackView(arrangedSubviews: [ let stackView = UIStackView(arrangedSubviews: [
titleLabel, titleRow,
topSpacer, webView
countryRow,
UIView.spacer(withHeight: 8),
phoneNumberRow,
bottomSpacer,
nextButton
]) ])
stackView.axis = .vertical stackView.axis = .vertical
stackView.alignment = .fill stackView.alignment = .fill
stackView.layoutMargins = UIEdgeInsets(top: 32, left: 32, bottom: 32, right: 32) stackView.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
stackView.isLayoutMarginsRelativeArrangement = true stackView.isLayoutMarginsRelativeArrangement = true
view.addSubview(stackView) view.addSubview(stackView)
stackView.autoPinWidthToSuperviewMargins() stackView.autoPinWidthToSuperviewMargins()
stackView.autoPinWidthToSuperviewMargins()
stackView.autoPin(toTopLayoutGuideOf: self, withInset: 0) stackView.autoPin(toTopLayoutGuideOf: self, withInset: 0)
autoPinView(toBottomOfViewControllerOrKeyboard: stackView, avoidNotch: true) autoPinView(toBottomOfViewControllerOrKeyboard: stackView, avoidNotch: true)
// Ensure whitespace is balanced, so inputs are vertically centered. NotificationCenter.default.addObserver(self,
topSpacer.autoMatch(.height, to: .height, of: bottomSpacer) selector: #selector(didBecomeActive),
name: NSNotification.Name.OWSApplicationDidBecomeActive,
object: nil)
} }
private func addBottomStroke(_ view: UIView) { deinit {
let strokeView = UIView() NotificationCenter.default.removeObserver(self)
strokeView.backgroundColor = Theme.middleGrayColor
view.addSubview(strokeView)
strokeView.autoSetDimension(.height, toSize: CGHairlineWidth())
strokeView.autoPinWidthToSuperview()
strokeView.autoPinEdge(toSuperviewEdge: .bottom)
} }
public override func viewWillAppear(_ animated: Bool) { public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
self.navigationController?.isNavigationBarHidden = false self.navigationController?.isNavigationBarHidden = false
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.navigationController?.isNavigationBarHidden = false
phoneNumberTextField.becomeFirstResponder()
if tsAccountManager.isReregistering() { loadContent()
// If re-registering, pre-populate the country (country code, calling code, country name)
// and phone number state.
guard let phoneNumberE164 = tsAccountManager.reregisterationPhoneNumber() else {
owsFailDebug("Could not resume re-registration; missing phone number.")
return
}
tryToReregister(phoneNumberE164: phoneNumberE164)
}
} }
private func tryToReregister(phoneNumberE164: String) { fileprivate let contentUrl = "https://signalcaptchas.org/registration/generate.html"
guard phoneNumberE164.count > 0 else {
owsFailDebug("Could not resume re-registration; invalid phoneNumberE164.") private func loadContent() {
return guard let webView = webView else {
} owsFailDebug("Missing webView.")
guard let parsedPhoneNumber = PhoneNumber(fromE164: phoneNumberE164) else {
owsFailDebug("Could not resume re-registration; couldn't parse phoneNumberE164.")
return
}
guard let callingCode = parsedPhoneNumber.getCountryCode() else {
owsFailDebug("Could not resume re-registration; missing callingCode.")
return
}
let callingCodeText = "\(COUNTRY_CODE_PREFIX)\(callingCode)"
let countryCodes: [String] =
PhoneNumberUtil.sharedThreadLocal().countryCodes(fromCallingCode: callingCodeText)
guard let countryCode = countryCodes.first else {
owsFailDebug("Could not resume re-registration; unknown countryCode.")
return
}
guard let countryName = PhoneNumberUtil.countryName(fromCountryCode: countryCode) else {
owsFailDebug("Could not resume re-registration; unknown countryName.")
return return
} }
if !phoneNumberE164.hasPrefix(callingCodeText) { guard let url = URL(string: contentUrl) else {
owsFailDebug("Could not resume re-registration; non-matching calling code.") owsFailDebug("Invalid URL.")
return return
} }
let phoneNumberWithoutCallingCode = phoneNumberE164.substring(from: callingCodeText.count) webView.load(URLRequest(url: url))
webView.scrollView.contentOffset = .zero
onboardingController.update(withCountryName: countryName, callingCode: callingCodeText, countryCode: countryCode)
updateState()
phoneNumberTextField.text = phoneNumberWithoutCallingCode
// Don't let user edit their phone number while re-registering.
phoneNumberTextField.isEnabled = false
} }
// MARK: - public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
private var countryName: String {
get {
return onboardingController.state.countryName
}
}
private var callingCode: String {
get {
AssertIsOnMainThread()
return onboardingController.state.callingCode
}
}
private var countryCode: String {
get {
AssertIsOnMainThread()
return onboardingController.state.countryCode
}
}
private func populateDefaults() { self.navigationController?.isNavigationBarHidden = false
if let lastRegisteredPhoneNumber = OnboardingController.lastRegisteredPhoneNumber(),
lastRegisteredPhoneNumber.count > 0,
lastRegisteredPhoneNumber.hasPrefix(callingCode) {
phoneNumberTextField.text = lastRegisteredPhoneNumber.substring(from: callingCode.count)
} }
updateState() // MARK: - Notifications
}
private func updateState() { @objc func didBecomeActive() {
AssertIsOnMainThread() AssertIsOnMainThread()
countryNameLabel.text = countryName loadContent()
callingCodeLabel.text = callingCode
self.phoneNumberTextField.placeholder = ViewControllerUtils.examplePhoneNumber(forCountryCode: countryCode, callingCode: callingCode)
} }
// MARK: - Events // MARK: - Events
@objc func countryRowTapped(sender: UIGestureRecognizer) { private func didComplete(url: URL) {
guard sender.state == .recognized else { Logger.info("")
return
}
showCountryPicker()
}
@objc func countryCodeTapped(sender: UIGestureRecognizer) { // signalcaptcha://03AF6jDqXgf1PocNNrWRJEENZ9l6RAMIsUoESi2dFKkxTgE2qjdZGVjEW6SZNFQqeRRTgGqOii6zHGG--uLyC1HnhSmRt8wHeKxHcg1hsK4ucTusANIeFXVB8wPPiV7U_0w2jUFVak5clMCvW9_JBfbfzj51_e9sou8DYfwc_R6THuTBTdpSV8Nh0yJalgget-nSukCxh6FPA6hRVbw7lP3r-me1QCykHOfh-V29UVaQ4Fs5upHvwB5rtiViqT_HN8WuGmdIdGcaWxaqy1lQTgFSs2Shdj593wZiXfhJnCWAw9rMn3jSgIZhkFxdXwKOmslQ2E_I8iWkm6
guard sender.state == .recognized else { guard let host = url.host,
host.count > 0 else {
owsFailDebug("Missing host.")
return return
} }
showCountryPicker()
}
@objc func nextPressed() {
Logger.info("")
parseAndTryToRegister() onboardingController.
} }
//
// @objc func countryRowTapped(sender: UIGestureRecognizer) {
// guard sender.state == .recognized else {
// return
// }
// showCountryPicker()
// }
//
// @objc func countryCodeTapped(sender: UIGestureRecognizer) {
// guard sender.state == .recognized else {
// return
// }
// showCountryPicker()
// }
//
// @objc func nextPressed() {
// Logger.info("")
//
// parseAndTryToRegister()
// }
//
// // MARK: -
//
// private func registrationSucceeded() {
// self.onboardingController.onboardingPhoneNumberDidComplete(viewController: self)
// }
}
// MARK: - Country Picker // MARK: -
private func showCountryPicker() { extension OnboardingCaptchaViewController: WKNavigationDelegate {
guard !tsAccountManager.isReregistering() else { public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
Logger.verbose("navigationAction: \(String(describing: navigationAction.request.url))")
guard let url: URL = navigationAction.request.url else {
owsFailDebug("Missing URL.")
decisionHandler(.cancel)
return return
} }
if url.scheme == "signalcaptcha" {
let countryCodeController = CountryCodeViewController() decisionHandler(.cancel)
countryCodeController.countryCodeDelegate = self DispatchQueue.main.async {
countryCodeController.interfaceOrientationMask = .portrait didComplete(url: url)
let navigationController = OWSNavigationController(rootViewController: countryCodeController)
self.present(navigationController, animated: true, completion: nil)
} }
// MARK: - Register
private func parseAndTryToRegister() {
guard let phoneNumberText = phoneNumberTextField.text?.ows_stripped(),
phoneNumberText.count > 0 else {
OWSAlerts.showAlert(title:
NSLocalizedString("REGISTRATION_VIEW_NO_PHONE_NUMBER_ALERT_TITLE",
comment: "Title of alert indicating that users needs to enter a phone number to register."),
message:
NSLocalizedString("REGISTRATION_VIEW_NO_PHONE_NUMBER_ALERT_MESSAGE",
comment: "Message of alert indicating that users needs to enter a phone number to register."))
return return
} }
let phoneNumber = "\(callingCode)\(phoneNumberText)" decisionHandler(.allow)
guard let localNumber = PhoneNumber.tryParsePhoneNumber(fromUserSpecifiedText: phoneNumber),
localNumber.toE164().count > 0,
PhoneNumberValidator().isValidForRegistration(phoneNumber: localNumber) else {
OWSAlerts.showAlert(title:
NSLocalizedString("REGISTRATION_VIEW_INVALID_PHONE_NUMBER_ALERT_TITLE",
comment: "Title of alert indicating that users needs to enter a valid phone number to register."),
message:
NSLocalizedString("REGISTRATION_VIEW_INVALID_PHONE_NUMBER_ALERT_MESSAGE",
comment: "Message of alert indicating that users needs to enter a valid phone number to register."))
return
}
let parsedPhoneNumber = localNumber.toE164()
if UIDevice.current.isIPad {
let countryCode = self.countryCode
OWSAlerts.showConfirmationAlert(title: NSLocalizedString("REGISTRATION_IPAD_CONFIRM_TITLE",
comment: "alert title when registering an iPad"),
message: NSLocalizedString("REGISTRATION_IPAD_CONFIRM_BODY",
comment: "alert body when registering an iPad"),
proceedTitle: NSLocalizedString("REGISTRATION_IPAD_CONFIRM_BUTTON",
comment: "button text to proceed with registration when on an iPad"),
proceedAction: { (_) in
self.tryToRegister(parsedPhoneNumber: parsedPhoneNumber,
phoneNumberText: phoneNumberText,
countryCode: countryCode)
})
} else {
tryToRegister(parsedPhoneNumber: parsedPhoneNumber,
phoneNumberText: phoneNumberText,
countryCode: countryCode)
}
} }
private func tryToRegister(parsedPhoneNumber: String, public func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
phoneNumberText: String, Logger.verbose("navigationResponse: \(String(describing: navigationResponse))")
countryCode: String) {
ModalActivityIndicatorViewController.present(fromViewController: self,
canCancel: true) { (modal) in
OnboardingController.setLastRegisteredCountryCode(value: countryCode)
OnboardingController.setLastRegisteredPhoneNumber(value: phoneNumberText)
self.tsAccountManager.register(withPhoneNumber: parsedPhoneNumber, decisionHandler(.allow)
success: {
DispatchQueue.main.async {
modal.dismiss(completion: {
self.registrationSucceeded()
})
} }
}, failure: { (error) in
Logger.error("Error: \(error)")
DispatchQueue.main.async { public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
modal.dismiss(completion: { Logger.verbose("navigation: \(String(describing: navigation))")
self.registrationFailed(error: error as NSError)
})
}
}, smsVerification: true)
}
} }
private func registrationSucceeded() { public func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!) {
self.onboardingController.onboardingPhoneNumberDidComplete(viewController: self) Logger.verbose("navigation: \(String(describing: navigation))")
} }
private func registrationFailed(error: NSError) { public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
if error.code == 402 { Logger.verbose("navigation: \(String(describing: navigation)), error: \(error)")
Logger.info("Captcha requested.")
self.onboardingController.onboardingPhoneNumberDidRequireCaptcha(viewController: self)
return
} else if error.code == 400 {
OWSAlerts.showAlert(title: NSLocalizedString("REGISTRATION_ERROR", comment: ""),
message: NSLocalizedString("REGISTRATION_NON_VALID_NUMBER", comment: ""))
} else {
OWSAlerts.showAlert(title: error.localizedDescription,
message: error.localizedRecoverySuggestion)
} }
phoneNumberTextField.becomeFirstResponder() public func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
Logger.verbose("navigation: \(String(describing: navigation))")
} }
}
// MARK: -
extension OnboardingCaptchaViewController: UITextFieldDelegate { public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { Logger.verbose("navigation: \(String(describing: navigation))")
// TODO: Fix auto-format of phone numbers.
ViewControllerUtils.phoneNumber(textField, shouldChangeCharactersIn: range, replacementString: string, countryCode: countryCode)
// Inform our caller that we took care of performing the change.
return false
}
public func textFieldShouldReturn(_ textField: UITextField) -> Bool {
parseAndTryToRegister()
textField.resignFirstResponder()
return false
} }
}
// MARK: -
extension OnboardingCaptchaViewController: CountryCodeViewControllerDelegate { public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
public func countryCodeViewController(_ vc: CountryCodeViewController, didSelectCountryCode countryCode: String, countryName: String, callingCode: String) { Logger.verbose("navigation: \(String(describing: navigation)), error: \(error)")
guard countryCode.count > 0 else {
owsFailDebug("Invalid country code.")
return
}
guard countryName.count > 0 else {
owsFailDebug("Invalid country name.")
return
}
guard callingCode.count > 0 else {
owsFailDebug("Invalid calling code.")
return
} }
onboardingController.update(withCountryName: countryName, callingCode: callingCode, countryCode: countryCode) // public func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)
updateState()
// Trigger the formatting logic with a no-op edit. public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
_ = textField(phoneNumberTextField, shouldChangeCharactersIn: NSRange(location: 0, length: 0), replacementString: "") Logger.verbose("")
} }
} }

@ -95,10 +95,22 @@ public class OnboardingController: NSObject {
viewController.navigationController?.pushViewController(view, animated: true) viewController.navigationController?.pushViewController(view, animated: true)
} }
public func onboardingCaptchaDidComplete(viewController: UIViewController,
captchaToken: String) {
AssertIsOnMainThread()
self.captchaToken = captchaToken
// let view = OnboardingCaptchaViewController(onboardingController: self)
// viewController.navigationController?.pushViewController(view, animated: true)
}
// MARK: - State // MARK: - State
public private(set) var state: OnboardingState = .defaultValue public private(set) var state: OnboardingState = .defaultValue
private var captchaToken: String
public func update(withCountryName countryName: String, callingCode: String, countryCode: String) { public func update(withCountryName countryName: String, callingCode: String, countryCode: String) {
AssertIsOnMainThread() AssertIsOnMainThread()

@ -116,7 +116,6 @@ public class OnboardingPhoneNumberViewController: OnboardingBaseViewController {
stackView.isLayoutMarginsRelativeArrangement = true stackView.isLayoutMarginsRelativeArrangement = true
view.addSubview(stackView) view.addSubview(stackView)
stackView.autoPinWidthToSuperviewMargins() stackView.autoPinWidthToSuperviewMargins()
stackView.autoPinWidthToSuperviewMargins()
stackView.autoPin(toTopLayoutGuideOf: self, withInset: 0) stackView.autoPin(toTopLayoutGuideOf: self, withInset: 0)
autoPinView(toBottomOfViewControllerOrKeyboard: stackView, avoidNotch: true) autoPinView(toBottomOfViewControllerOrKeyboard: stackView, avoidNotch: true)

@ -1092,11 +1092,11 @@
/* Label for button that resets crop & rotation state. */ /* Label for button that resets crop & rotation state. */
"IMAGE_EDITOR_RESET_BUTTON" = "Reset"; "IMAGE_EDITOR_RESET_BUTTON" = "Reset";
/* Label for button that rotates image 90 degrees. */ /* Label for button that rotates image 45 degrees. */
"IMAGE_EDITOR_ROTATE_90_BUTTON" = "Rotate 90°"; "IMAGE_EDITOR_ROTATE_45_BUTTON" = "Rotate 45°";
/* Label for button that rotates image 90 degrees. */ /* Label for button that rotates image 90 degrees. */
"IMAGE_EDITOR_ROTATE_45_BUTTON" = "Rotate 45°"; "IMAGE_EDITOR_ROTATE_90_BUTTON" = "Rotate 90°";
/* Momentarily shown to the user when attempting to select more images than is allowed. Embeds {{max number of items}} that can be shared. */ /* Momentarily shown to the user when attempting to select more images than is allowed. Embeds {{max number of items}} that can be shared. */
"IMAGE_PICKER_CAN_SELECT_NO_MORE_TOAST_FORMAT" = "You can't share more than %@ items."; "IMAGE_PICKER_CAN_SELECT_NO_MORE_TOAST_FORMAT" = "You can't share more than %@ items.";
@ -1508,6 +1508,9 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"OK" = "OK"; "OK" = "OK";
/* Title of the 'onboarding Captcha' view. */
"ONBOARDING_CAPTCHA_TITLE" = "We need to verify that you're human";
/* Explanation in the 'onboarding permissions' view. */ /* Explanation in the 'onboarding permissions' view. */
"ONBOARDING_PERMISSIONS_EXPLANATION" = "ONBOARDING_PERMISSIONS_EXPLANATION"; "ONBOARDING_PERMISSIONS_EXPLANATION" = "ONBOARDING_PERMISSIONS_EXPLANATION";

Loading…
Cancel
Save