Dactyloidae iOS initial commit
|
|
@ -0,0 +1,183 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import UIKit
|
||||
import Shared
|
||||
import SnapKit
|
||||
import Foundation
|
||||
import FxA
|
||||
import Account
|
||||
|
||||
class AdvanceAccountSettingViewController: SettingsTableViewController {
|
||||
fileprivate let SectionHeaderIdentifier = "SectionHeaderIdentifier"
|
||||
|
||||
fileprivate var customSyncUrl: String?
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = Strings.SettingsAdvanceAccountSectionName
|
||||
self.customSyncUrl = self.profile.prefs.stringForKey(PrefsKeys.KeyCustomSyncWeb)
|
||||
}
|
||||
|
||||
func clearCustomAccountPrefs() {
|
||||
self.profile.prefs.setBool(false, forKey: PrefsKeys.KeyUseCustomSyncService)
|
||||
self.profile.prefs.setString("", forKey: PrefsKeys.KeyCustomSyncToken)
|
||||
self.profile.prefs.setString("", forKey: PrefsKeys.KeyCustomSyncProfile)
|
||||
self.profile.prefs.setString("", forKey: PrefsKeys.KeyCustomSyncOauth)
|
||||
self.profile.prefs.setString("", forKey: PrefsKeys.KeyCustomSyncAuth)
|
||||
self.profile.prefs.setString("", forKey: PrefsKeys.KeyCustomSyncWeb)
|
||||
|
||||
// To help prevent the account being in a strange state, we force it to
|
||||
// log out when user clears their custom server preferences.
|
||||
self.profile.removeAccount()
|
||||
}
|
||||
|
||||
func setCustomAccountPrefs(_ data: Data, url: URL) {
|
||||
guard let settings = (try? JSONSerialization.jsonObject(with: data, options: .allowFragments)) as? [String:Any],
|
||||
let customSyncToken = settings["sync_tokenserver_base_url"] as? String,
|
||||
let customSyncProfile = settings["profile_server_base_url"] as? String,
|
||||
let customSyncOauth = settings["oauth_server_base_url"] as? String,
|
||||
let customSyncAuth = settings["auth_server_base_url"] as? String else {
|
||||
return
|
||||
}
|
||||
|
||||
self.profile.prefs.setBool(true, forKey: PrefsKeys.KeyUseCustomSyncService)
|
||||
self.profile.prefs.setString(customSyncToken, forKey: PrefsKeys.KeyCustomSyncToken)
|
||||
self.profile.prefs.setString(customSyncProfile, forKey: PrefsKeys.KeyCustomSyncProfile)
|
||||
self.profile.prefs.setString(customSyncOauth, forKey: PrefsKeys.KeyCustomSyncOauth)
|
||||
self.profile.prefs.setString(customSyncAuth, forKey: PrefsKeys.KeyCustomSyncAuth)
|
||||
self.profile.prefs.setString(url.absoluteString, forKey: PrefsKeys.KeyCustomSyncWeb)
|
||||
self.profile.removeAccount()
|
||||
self.displaySuccessAlert()
|
||||
}
|
||||
|
||||
func setCustomAccountPrefs() {
|
||||
guard let urlString = self.customSyncUrl, let url = URL(string: urlString) else {
|
||||
// If the user attempts to set a nil url, clear all the custom service perferences
|
||||
// and use default FxA servers.
|
||||
self.displayNoServiceSetAlert()
|
||||
return
|
||||
}
|
||||
|
||||
// FxA stores its server configuation under a well-known path. This attempts to download the configuration
|
||||
// and save it into the users preferences.
|
||||
let syncConfigureString = urlString + "/.well-known/fxa-client-configuration"
|
||||
guard let syncConfigureURL = URL(string: syncConfigureString) else {
|
||||
return
|
||||
}
|
||||
|
||||
URLSession.shared.dataTask(with: syncConfigureURL, completionHandler: {(data, response, error) in
|
||||
guard let data = data, error == nil else {
|
||||
// Something went wrong while downloading or parsing the configuration.
|
||||
self.displayErrorAlert()
|
||||
return
|
||||
}
|
||||
self.setCustomAccountPrefs(data, url: url)
|
||||
}).resume()
|
||||
}
|
||||
|
||||
func displaySuccessAlert() {
|
||||
let alertController = UIAlertController(title: "", message: Strings.SettingsAdvanceAccountUrlUpdatedAlertMessage, preferredStyle: .alert)
|
||||
let defaultAction = UIAlertAction(title: Strings.SettingsAdvanceAccountUrlUpdatedAlertOk, style: .default, handler: nil)
|
||||
alertController.addAction(defaultAction)
|
||||
self.present(alertController, animated: true)
|
||||
}
|
||||
|
||||
func displayErrorAlert() {
|
||||
self.profile.prefs.setBool(false, forKey: PrefsKeys.KeyUseCustomSyncService)
|
||||
DispatchQueue.main.async {
|
||||
self.tableView.reloadRows(at: [IndexPath(row: 0, section: 0)], with: UITableViewRowAnimation.automatic)
|
||||
}
|
||||
let alertController = UIAlertController(title: Strings.SettingsAdvanceAccountUrlErrorAlertTitle, message: Strings.SettingsAdvanceAccountUrlErrorAlertMessage, preferredStyle: .alert)
|
||||
let defaultAction = UIAlertAction(title: Strings.SettingsAdvanceAccountUrlErrorAlertOk, style: .default, handler: nil)
|
||||
alertController.addAction(defaultAction)
|
||||
self.present(alertController, animated: true)
|
||||
}
|
||||
|
||||
func displayNoServiceSetAlert() {
|
||||
self.profile.prefs.setBool(false, forKey: PrefsKeys.KeyUseCustomSyncService)
|
||||
DispatchQueue.main.async {
|
||||
self.tableView.reloadRows(at: [IndexPath(row: 0, section: 0)], with: UITableViewRowAnimation.automatic)
|
||||
}
|
||||
let alertController = UIAlertController(title: Strings.SettingsAdvanceAccountUrlErrorAlertTitle, message: Strings.SettingsAdvanceAccountEmptyUrlErrorAlertMessage, preferredStyle: .alert)
|
||||
let defaultAction = UIAlertAction(title: Strings.SettingsAdvanceAccountUrlUpdatedAlertOk, style: .default, handler: nil)
|
||||
alertController.addAction(defaultAction)
|
||||
self.present(alertController, animated: true)
|
||||
}
|
||||
|
||||
override func generateSettings() -> [SettingSection] {
|
||||
let prefs = profile.prefs
|
||||
let customSyncSetting = CustomSyncWebPageSetting(prefs: prefs,
|
||||
prefKey: PrefsKeys.KeyCustomSyncWeb,
|
||||
placeholder: Strings.SettingsAdvanceAccountUrlPlaceholder,
|
||||
accessibilityIdentifier: "CustomSyncSetting",
|
||||
settingDidChange: { fieldText in
|
||||
self.customSyncUrl = fieldText
|
||||
if let customSyncUrl = self.customSyncUrl, customSyncUrl.isEmpty {
|
||||
self.clearCustomAccountPrefs()
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
var basicSettings: [Setting] = []
|
||||
basicSettings += [
|
||||
CustomSyncEnableSetting(
|
||||
prefs: prefs,
|
||||
settingDidChange: { result in
|
||||
if result == true {
|
||||
// Reload the table data to ensure that the updated custom url is set
|
||||
self.tableView?.reloadData()
|
||||
self.setCustomAccountPrefs()
|
||||
}
|
||||
}),
|
||||
customSyncSetting
|
||||
]
|
||||
|
||||
let settings: [SettingSection] = [
|
||||
SettingSection(title: NSAttributedString(string: ""), children: basicSettings),
|
||||
SettingSection(title: NSAttributedString(string: Strings.SettingsAdvanceAccountSectionFooter), children: [])
|
||||
]
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: SectionHeaderIdentifier) as! SettingsTableSectionHeaderFooterView
|
||||
let sectionSetting = settings[section]
|
||||
headerView.titleLabel.text = sectionSetting.title?.string
|
||||
|
||||
switch section {
|
||||
// Hide the bottom border for the FxA custom server notes.
|
||||
case 1:
|
||||
headerView.titleAlignment = .top
|
||||
headerView.titleLabel.numberOfLines = 0
|
||||
headerView.showBottomBorder = false
|
||||
default:
|
||||
return super.tableView(tableView, viewForHeaderInSection: section)
|
||||
}
|
||||
return headerView
|
||||
}
|
||||
}
|
||||
|
||||
class CustomSyncEnableSetting: BoolSetting {
|
||||
init(prefs: Prefs, settingDidChange: ((Bool?) -> Void)? = nil) {
|
||||
super.init(
|
||||
prefs: prefs, prefKey: PrefsKeys.KeyUseCustomSyncService, defaultValue: false,
|
||||
attributedTitleText: NSAttributedString(string: Strings.SettingsAdvanceAccountUseCustomAccountsServiceTitle),
|
||||
settingDidChange: settingDidChange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class CustomSyncWebPageSetting: WebPageSetting {
|
||||
override init(prefs: Prefs, prefKey: String, defaultValue: String? = nil, placeholder: String, accessibilityIdentifier: String, settingDidChange: ((String?) -> Void)? = nil) {
|
||||
super.init(prefs: prefs,
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
placeholder: placeholder,
|
||||
accessibilityIdentifier: accessibilityIdentifier,
|
||||
settingDidChange: settingDidChange)
|
||||
textField.clearButtonMode = UITextFieldViewMode.always
|
||||
}
|
||||
}
|
||||
1092
mobile/ios/Client/Frontend/Settings/AppSettingsOptions.swift
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Shared
|
||||
import Account
|
||||
|
||||
/// App Settings Screen (triggered by tapping the 'Gear' in the Tab Tray Controller)
|
||||
class AppSettingsTableViewController: SettingsTableViewController {
|
||||
fileprivate let SectionHeaderIdentifier = "SectionHeaderIdentifier"
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
navigationItem.title = NSLocalizedString("Settings", comment: "Title in the settings view controller title bar")
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(
|
||||
title: NSLocalizedString("Done", comment: "Done button on left side of the Settings view controller title bar"),
|
||||
style: UIBarButtonItemStyle.done,
|
||||
target: navigationController, action: #selector((navigationController as! SettingsNavigationController).SELdone))
|
||||
navigationItem.leftBarButtonItem?.accessibilityIdentifier = "AppSettingsTableViewController.navigationItem.leftBarButtonItem"
|
||||
|
||||
tableView.accessibilityIdentifier = "AppSettingsTableViewController.tableView"
|
||||
|
||||
// Refresh the user's FxA profile upon viewing settings. This will update their avatar,
|
||||
// display name, etc.
|
||||
if AppConstants.MOZ_SHOW_FXA_AVATAR {
|
||||
profile.getAccount()?.updateProfile()
|
||||
}
|
||||
}
|
||||
|
||||
override func generateSettings() -> [SettingSection] {
|
||||
var settings = [SettingSection]()
|
||||
|
||||
let privacyTitle = NSLocalizedString("Privacy", comment: "Privacy section title")
|
||||
let accountDebugSettings = [
|
||||
// Debug settings:
|
||||
RequirePasswordDebugSetting(settings: self),
|
||||
RequireUpgradeDebugSetting(settings: self),
|
||||
ForgetSyncAuthStateDebugSetting(settings: self),
|
||||
StageSyncServiceDebugSetting(settings: self),
|
||||
]
|
||||
|
||||
let prefs = profile.prefs
|
||||
var generalSettings: [Setting] = [
|
||||
SearchSetting(settings: self),
|
||||
NewTabPageSetting(settings: self),
|
||||
HomePageSetting(settings: self),
|
||||
OpenWithSetting(settings: self),
|
||||
BoolSetting(prefs: prefs, prefKey: "blockPopups", defaultValue: true,
|
||||
titleText: NSLocalizedString("Block Pop-up Windows", comment: "Block pop-up windows setting")),
|
||||
BoolSetting(prefs: prefs, prefKey: "saveLogins", defaultValue: true,
|
||||
titleText: NSLocalizedString("Save Logins", comment: "Setting to enable the built-in password manager")),
|
||||
]
|
||||
|
||||
let accountChinaSyncSetting: [Setting]
|
||||
if !profile.isChinaEdition {
|
||||
accountChinaSyncSetting = []
|
||||
} else {
|
||||
accountChinaSyncSetting = [
|
||||
// Show China sync service setting:
|
||||
ChinaSyncServiceSetting(settings: self)
|
||||
]
|
||||
}
|
||||
// There is nothing to show in the Customize section if we don't include the compact tab layout
|
||||
// setting on iPad. When more options are added that work on both device types, this logic can
|
||||
// be changed.
|
||||
|
||||
if AppConstants.MOZ_CLIPBOARD_BAR {
|
||||
generalSettings += [
|
||||
BoolSetting(prefs: prefs, prefKey: "showClipboardBar", defaultValue: false,
|
||||
titleText: Strings.SettingsOfferClipboardBarTitle,
|
||||
statusText: Strings.SettingsOfferClipboardBarStatus)
|
||||
]
|
||||
}
|
||||
|
||||
var accountSectionTitle: NSAttributedString?
|
||||
if AppConstants.MOZ_SHOW_FXA_AVATAR {
|
||||
accountSectionTitle = NSAttributedString(string: Strings.FxAFirefoxAccount)
|
||||
}
|
||||
|
||||
let footerText = !profile.hasAccount() ? NSAttributedString(string: Strings.FxASyncUsageDetails) : nil
|
||||
settings += [
|
||||
SettingSection(title: accountSectionTitle, footerTitle: footerText, children: [
|
||||
// Without a Firefox Account:
|
||||
ConnectSetting(settings: self),
|
||||
AdvanceAccountSetting(settings: self),
|
||||
// With a Firefox Account:
|
||||
AccountStatusSetting(settings: self),
|
||||
SyncNowSetting(settings: self)
|
||||
] + accountChinaSyncSetting + accountDebugSettings)]
|
||||
|
||||
settings += [ SettingSection(title: NSAttributedString(string: NSLocalizedString("General", comment: "General settings section title")), children: generalSettings)]
|
||||
|
||||
var privacySettings = [Setting]()
|
||||
privacySettings.append(LoginsSetting(settings: self, delegate: settingsDelegate))
|
||||
privacySettings.append(TouchIDPasscodeSetting(settings: self))
|
||||
|
||||
privacySettings.append(ClearPrivateDataSetting(settings: self))
|
||||
|
||||
privacySettings += [
|
||||
BoolSetting(prefs: prefs,
|
||||
prefKey: "settings.closePrivateTabs",
|
||||
defaultValue: false,
|
||||
titleText: NSLocalizedString("Close Private Tabs", tableName: "PrivateBrowsing", comment: "Setting for closing private tabs"),
|
||||
statusText: NSLocalizedString("When Leaving Private Browsing", tableName: "PrivateBrowsing", comment: "Will be displayed in Settings under 'Close Private Tabs'"))
|
||||
]
|
||||
|
||||
if #available(iOS 11, *) {
|
||||
privacySettings.append(ContentBlockerSetting(settings: self))
|
||||
}
|
||||
|
||||
privacySettings += [
|
||||
PrivacyPolicySetting()
|
||||
]
|
||||
|
||||
settings += [
|
||||
SettingSection(title: NSAttributedString(string: privacyTitle), children: privacySettings),
|
||||
SettingSection(title: NSAttributedString(string: NSLocalizedString("Support", comment: "Support section title")), children: [
|
||||
ShowIntroductionSetting(settings: self),
|
||||
SendFeedbackSetting(),
|
||||
SendAnonymousUsageDataSetting(prefs: prefs, delegate: settingsDelegate),
|
||||
OpenSupportPageSetting(delegate: settingsDelegate),
|
||||
]),
|
||||
SettingSection(title: NSAttributedString(string: NSLocalizedString("About", comment: "About settings section title")), children: [
|
||||
VersionSetting(settings: self),
|
||||
LicenseAndAcknowledgementsSetting(),
|
||||
YourRightsSetting(),
|
||||
ExportBrowserDataSetting(settings: self),
|
||||
DeleteExportedDataSetting(settings: self),
|
||||
EnableBookmarkMergingSetting(settings: self),
|
||||
ForceCrashSetting(settings: self)
|
||||
])]
|
||||
|
||||
if profile.hasAccount() {
|
||||
settings += [SettingSection(title: nil, footerTitle: NSAttributedString(string: ""), children: [DisconnectSetting(settings: self)])]
|
||||
}
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
let headerView = super.tableView(tableView, viewForHeaderInSection: section) as! SettingsTableSectionHeaderFooterView
|
||||
// Prevent the top border from showing for the General section.
|
||||
if !profile.hasAccount() {
|
||||
switch section {
|
||||
case 1:
|
||||
headerView.showTopBorder = false
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return headerView
|
||||
}
|
||||
}
|
||||
|
||||
extension AppSettingsTableViewController {
|
||||
func navigateToLoginsList() {
|
||||
let viewController = LoginListViewController(profile: profile)
|
||||
viewController.settingsDelegate = settingsDelegate
|
||||
navigationController?.pushViewController(viewController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension AppSettingsTableViewController: PasscodeEntryDelegate {
|
||||
@objc func passcodeValidationDidSucceed() {
|
||||
navigationController?.dismiss(animated: true) {
|
||||
self.navigateToLoginsList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import UIKit
|
||||
import Shared
|
||||
|
||||
private let SectionToggles = 0
|
||||
private let SectionButton = 1
|
||||
private let NumberOfSections = 2
|
||||
private let SectionHeaderFooterIdentifier = "SectionHeaderFooterIdentifier"
|
||||
private let TogglesPrefKey = "clearprivatedata.toggles"
|
||||
|
||||
private let log = Logger.browserLogger
|
||||
|
||||
private let HistoryClearableIndex = 0
|
||||
|
||||
class ClearPrivateDataTableViewController: UITableViewController {
|
||||
fileprivate var clearButton: UITableViewCell?
|
||||
|
||||
var profile: Profile!
|
||||
var tabManager: TabManager!
|
||||
|
||||
fileprivate typealias DefaultCheckedState = Bool
|
||||
|
||||
fileprivate lazy var clearables: [(clearable: Clearable, checked: DefaultCheckedState)] = {
|
||||
return [
|
||||
(HistoryClearable(profile: self.profile), true),
|
||||
(CacheClearable(tabManager: self.tabManager), true),
|
||||
(CookiesClearable(tabManager: self.tabManager), true),
|
||||
(SiteDataClearable(tabManager: self.tabManager), true),
|
||||
]
|
||||
}()
|
||||
|
||||
fileprivate lazy var toggles: [Bool] = {
|
||||
if let savedToggles = self.profile.prefs.arrayForKey(TogglesPrefKey) as? [Bool] {
|
||||
return savedToggles
|
||||
}
|
||||
|
||||
return self.clearables.map { $0.checked }
|
||||
}()
|
||||
|
||||
fileprivate var clearButtonEnabled = true {
|
||||
didSet {
|
||||
clearButton?.textLabel?.textColor = clearButtonEnabled ? UIConstants.DestructiveRed : UIColor.lightGray
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
title = Strings.SettingsClearPrivateDataTitle
|
||||
|
||||
tableView.register(SettingsTableSectionHeaderFooterView.self, forHeaderFooterViewReuseIdentifier: SectionHeaderFooterIdentifier)
|
||||
|
||||
tableView.separatorColor = UIConstants.TableViewSeparatorColor
|
||||
tableView.backgroundColor = UIConstants.TableViewHeaderBackgroundColor
|
||||
let footer = SettingsTableSectionHeaderFooterView(frame: CGRect(x: 0, y: 0, width: tableView.bounds.width, height: UIConstants.TableViewHeaderFooterHeight))
|
||||
footer.showBottomBorder = false
|
||||
tableView.tableFooterView = footer
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: UITableViewCellStyle.default, reuseIdentifier: nil)
|
||||
|
||||
if indexPath.section == SectionToggles {
|
||||
cell.textLabel?.text = clearables[indexPath.item].clearable.label
|
||||
let control = UISwitch()
|
||||
control.onTintColor = UIConstants.ControlTintColor
|
||||
control.addTarget(self, action: #selector(ClearPrivateDataTableViewController.switchValueChanged(_:)), for: UIControlEvents.valueChanged)
|
||||
control.isOn = toggles[indexPath.item]
|
||||
cell.accessoryView = control
|
||||
cell.selectionStyle = .none
|
||||
control.tag = indexPath.item
|
||||
} else {
|
||||
assert(indexPath.section == SectionButton)
|
||||
cell.textLabel?.text = Strings.SettingsClearPrivateDataClearButton
|
||||
cell.textLabel?.textAlignment = NSTextAlignment.center
|
||||
cell.textLabel?.textColor = UIConstants.DestructiveRed
|
||||
cell.accessibilityTraits = UIAccessibilityTraitButton
|
||||
cell.accessibilityIdentifier = "ClearPrivateData"
|
||||
clearButton = cell
|
||||
}
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return NumberOfSections
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
if section == SectionToggles {
|
||||
return clearables.count
|
||||
}
|
||||
|
||||
assert(section == SectionButton)
|
||||
return 1
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool {
|
||||
guard indexPath.section == SectionButton else { return false }
|
||||
|
||||
// Highlight the button only if it's enabled.
|
||||
return clearButtonEnabled
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
guard indexPath.section == SectionButton else { return }
|
||||
|
||||
func clearPrivateData(_ action: UIAlertAction) {
|
||||
let toggles = self.toggles
|
||||
self.clearables
|
||||
.enumerated()
|
||||
.flatMap { (i, pair) in
|
||||
guard toggles[i] else {
|
||||
return nil
|
||||
}
|
||||
log.debug("Clearing \(pair.clearable).")
|
||||
return pair.clearable.clear()
|
||||
}
|
||||
.allSucceed()
|
||||
.upon { result in
|
||||
assert(result.isSuccess, "Private data cleared successfully")
|
||||
|
||||
LeanPlumClient.shared.track(event: .clearPrivateData)
|
||||
|
||||
self.profile.prefs.setObject(self.toggles, forKey: TogglesPrefKey)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
// Disable the Clear Private Data button after it's clicked.
|
||||
self.clearButtonEnabled = false
|
||||
self.tableView.deselectRow(at: indexPath, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We have been asked to clear history and we have an account.
|
||||
// (Whether or not it's in a good state is irrelevant.)
|
||||
if self.toggles[HistoryClearableIndex] && profile.hasAccount() {
|
||||
profile.syncManager.hasSyncedHistory().uponQueue(DispatchQueue.main) { yes in
|
||||
// Err on the side of warning, but this shouldn't fail.
|
||||
let alert: UIAlertController
|
||||
if yes.successValue ?? true {
|
||||
// Our local database contains some history items that have been synced.
|
||||
// Warn the user before clearing.
|
||||
alert = UIAlertController.clearSyncedHistoryAlert(okayCallback: clearPrivateData)
|
||||
} else {
|
||||
alert = UIAlertController.clearPrivateDataAlert(okayCallback: clearPrivateData)
|
||||
}
|
||||
self.present(alert, animated: true, completion: nil)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
let alert = UIAlertController.clearPrivateDataAlert(okayCallback: clearPrivateData)
|
||||
self.present(alert, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
tableView.deselectRow(at: indexPath, animated: false)
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
return tableView.dequeueReusableHeaderFooterView(withIdentifier: SectionHeaderFooterIdentifier) as! SettingsTableSectionHeaderFooterView
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
||||
return UIConstants.TableViewHeaderFooterHeight
|
||||
}
|
||||
|
||||
@objc func switchValueChanged(_ toggle: UISwitch) {
|
||||
toggles[toggle.tag] = toggle.isOn
|
||||
|
||||
// Dim the clear button if no clearables are selected.
|
||||
clearButtonEnabled = toggles.contains(true)
|
||||
}
|
||||
}
|
||||
149
mobile/ios/Client/Frontend/Settings/Clearables.swift
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import Foundation
|
||||
import Shared
|
||||
import WebKit
|
||||
import Deferred
|
||||
import SDWebImage
|
||||
import CoreSpotlight
|
||||
|
||||
private let log = Logger.browserLogger
|
||||
|
||||
// Removed Clearables as part of Bug 1226654, but keeping the string around.
|
||||
private let removedSavedLoginsLabel = NSLocalizedString("Saved Logins", tableName: "ClearPrivateData", comment: "Settings item for clearing passwords and login data")
|
||||
|
||||
// A base protocol for something that can be cleared.
|
||||
protocol Clearable {
|
||||
func clear() -> Success
|
||||
var label: String { get }
|
||||
}
|
||||
|
||||
class ClearableError: MaybeErrorType {
|
||||
fileprivate let msg: String
|
||||
init(msg: String) {
|
||||
self.msg = msg
|
||||
}
|
||||
|
||||
var description: String { return msg }
|
||||
}
|
||||
|
||||
// Clears our browsing history, including favicons and thumbnails.
|
||||
class HistoryClearable: Clearable {
|
||||
let profile: Profile
|
||||
init(profile: Profile) {
|
||||
self.profile = profile
|
||||
}
|
||||
|
||||
var label: String {
|
||||
return NSLocalizedString("Browsing History", tableName: "ClearPrivateData", comment: "Settings item for clearing browsing history")
|
||||
}
|
||||
|
||||
func clear() -> Success {
|
||||
return profile.history.clearHistory().bindQueue(.main) { success in
|
||||
SDImageCache.shared().clearDisk()
|
||||
SDImageCache.shared().clearMemory()
|
||||
self.profile.recentlyClosedTabs.clearTabs()
|
||||
CSSearchableIndex.default().deleteAllSearchableItems()
|
||||
NotificationCenter.default.post(name: NotificationPrivateDataClearedHistory, object: nil)
|
||||
log.debug("HistoryClearable succeeded: \(success).")
|
||||
return Deferred(value: success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ClearableErrorType: MaybeErrorType {
|
||||
let err: Error
|
||||
|
||||
init(err: Error) {
|
||||
self.err = err
|
||||
}
|
||||
|
||||
var description: String {
|
||||
return "Couldn't clear: \(err)."
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the web cache. Note, this has to close all open tabs in order to ensure the data
|
||||
// cached in them isn't flushed to disk.
|
||||
class CacheClearable: Clearable {
|
||||
let tabManager: TabManager
|
||||
init(tabManager: TabManager) {
|
||||
self.tabManager = tabManager
|
||||
}
|
||||
|
||||
var label: String {
|
||||
return NSLocalizedString("Cache", tableName: "ClearPrivateData", comment: "Settings item for clearing the cache")
|
||||
}
|
||||
|
||||
func clear() -> Success {
|
||||
let dataTypes = Set([WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache])
|
||||
WKWebsiteDataStore.default().removeData(ofTypes: dataTypes, modifiedSince: Date.distantPast, completionHandler: {})
|
||||
|
||||
log.debug("CacheClearable succeeded.")
|
||||
return succeed()
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteLibraryFolderContents(_ folder: String) throws {
|
||||
let manager = FileManager.default
|
||||
let library = manager.urls(for: FileManager.SearchPathDirectory.libraryDirectory, in: .userDomainMask)[0]
|
||||
let dir = library.appendingPathComponent(folder)
|
||||
let contents = try manager.contentsOfDirectory(atPath: dir.path)
|
||||
for content in contents {
|
||||
do {
|
||||
try manager.removeItem(at: dir.appendingPathComponent(content))
|
||||
} catch where ((error as NSError).userInfo[NSUnderlyingErrorKey] as? NSError)?.code == Int(EPERM) {
|
||||
// "Not permitted". We ignore this.
|
||||
log.debug("Couldn't delete some library contents.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteLibraryFolder(_ folder: String) throws {
|
||||
let manager = FileManager.default
|
||||
let library = manager.urls(for: FileManager.SearchPathDirectory.libraryDirectory, in: .userDomainMask)[0]
|
||||
let dir = library.appendingPathComponent(folder)
|
||||
try manager.removeItem(at: dir)
|
||||
}
|
||||
|
||||
// Removes all app cache storage.
|
||||
class SiteDataClearable: Clearable {
|
||||
let tabManager: TabManager
|
||||
init(tabManager: TabManager) {
|
||||
self.tabManager = tabManager
|
||||
}
|
||||
|
||||
var label: String {
|
||||
return NSLocalizedString("Offline Website Data", tableName: "ClearPrivateData", comment: "Settings item for clearing website data")
|
||||
}
|
||||
|
||||
func clear() -> Success {
|
||||
let dataTypes = Set([WKWebsiteDataTypeOfflineWebApplicationCache])
|
||||
WKWebsiteDataStore.default().removeData(ofTypes: dataTypes, modifiedSince: Date.distantPast, completionHandler: {})
|
||||
|
||||
log.debug("SiteDataClearable succeeded.")
|
||||
return succeed()
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all cookies stored by the site. This includes localStorage, sessionStorage, and WebSQL/IndexedDB.
|
||||
class CookiesClearable: Clearable {
|
||||
let tabManager: TabManager
|
||||
init(tabManager: TabManager) {
|
||||
self.tabManager = tabManager
|
||||
}
|
||||
|
||||
var label: String {
|
||||
return NSLocalizedString("Cookies", tableName: "ClearPrivateData", comment: "Settings item for clearing cookies")
|
||||
}
|
||||
|
||||
func clear() -> Success {
|
||||
let dataTypes = Set([WKWebsiteDataTypeCookies, WKWebsiteDataTypeLocalStorage, WKWebsiteDataTypeSessionStorage, WKWebsiteDataTypeWebSQLDatabases, WKWebsiteDataTypeIndexedDBDatabases])
|
||||
WKWebsiteDataStore.default().removeData(ofTypes: dataTypes, modifiedSince: Date.distantPast, completionHandler: {})
|
||||
|
||||
log.debug("CookiesClearable succeeded.")
|
||||
return succeed()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import UIKit
|
||||
import Shared
|
||||
import SnapKit
|
||||
import Storage
|
||||
import SDWebImage
|
||||
import Deferred
|
||||
|
||||
private let log = Logger.browserLogger
|
||||
|
||||
class CustomSearchError: MaybeErrorType {
|
||||
|
||||
enum Reason {
|
||||
case DuplicateEngine, FormInput
|
||||
}
|
||||
|
||||
var reason: Reason!
|
||||
|
||||
internal var description: String {
|
||||
return "Search Engine Not Added"
|
||||
}
|
||||
|
||||
init(_ reason: Reason) {
|
||||
self.reason = reason
|
||||
}
|
||||
}
|
||||
|
||||
class CustomSearchViewController: SettingsTableViewController {
|
||||
|
||||
fileprivate var urlString: String?
|
||||
fileprivate var engineTitle = ""
|
||||
fileprivate lazy var spinnerView: UIActivityIndicatorView = {
|
||||
let spinner = UIActivityIndicatorView(activityIndicatorStyle: .gray)
|
||||
spinner.hidesWhenStopped = true
|
||||
return spinner
|
||||
}()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = Strings.SettingsAddCustomEngineTitle
|
||||
view.addSubview(spinnerView)
|
||||
spinnerView.snp.makeConstraints { make in
|
||||
make.center.equalTo(self.view.snp.center)
|
||||
}
|
||||
}
|
||||
|
||||
var successCallback: (() -> Void)?
|
||||
|
||||
fileprivate func addSearchEngine(_ searchQuery: String, title: String) {
|
||||
spinnerView.startAnimating()
|
||||
|
||||
let trimmedQuery = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
createEngine(forQuery: trimmedQuery, andName: trimmedTitle).uponQueue(DispatchQueue.main) { result in
|
||||
self.spinnerView.stopAnimating()
|
||||
guard let engine = result.successValue else {
|
||||
let alert: UIAlertController
|
||||
let error = result.failureValue as? CustomSearchError
|
||||
|
||||
alert = (error?.reason == .DuplicateEngine) ?
|
||||
ThirdPartySearchAlerts.duplicateCustomEngine() : ThirdPartySearchAlerts.incorrectCustomEngineForm()
|
||||
|
||||
self.navigationItem.rightBarButtonItem?.isEnabled = true
|
||||
self.present(alert, animated: true, completion: nil)
|
||||
return
|
||||
}
|
||||
self.profile.searchEngines.addSearchEngine(engine)
|
||||
|
||||
CATransaction.begin() // Use transaction to call callback after animation has been completed
|
||||
CATransaction.setCompletionBlock(self.successCallback)
|
||||
_ = self.navigationController?.popViewController(animated: true)
|
||||
CATransaction.commit()
|
||||
}
|
||||
}
|
||||
|
||||
func createEngine(forQuery query: String, andName name: String) -> Deferred<Maybe<OpenSearchEngine>> {
|
||||
let deferred = Deferred<Maybe<OpenSearchEngine>>()
|
||||
guard let template = getSearchTemplate(withString: query),
|
||||
let url = URL(string: template.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlFragmentAllowed)!), url.isWebPage() else {
|
||||
deferred.fill(Maybe(failure: CustomSearchError(.FormInput)))
|
||||
return deferred
|
||||
}
|
||||
|
||||
// ensure we haven't already stored this template
|
||||
guard engineExists(name: name, template: template) == false else {
|
||||
deferred.fill(Maybe(failure: CustomSearchError(.DuplicateEngine)))
|
||||
return deferred
|
||||
}
|
||||
|
||||
FaviconFetcher.fetchFavImageForURL(forURL: url, profile: profile).uponQueue(DispatchQueue.main) { result in
|
||||
let image = result.successValue ?? FaviconFetcher.getDefaultFavicon(url)
|
||||
let engine = OpenSearchEngine(engineID: nil, shortName: name, image: image, searchTemplate: template, suggestTemplate: nil, isCustomEngine: true)
|
||||
|
||||
//Make sure a valid scheme is used
|
||||
let url = engine.searchURLForQuery("test")
|
||||
let maybe = (url == nil) ? Maybe(failure: CustomSearchError(.FormInput)) : Maybe(success: engine)
|
||||
deferred.fill(maybe)
|
||||
}
|
||||
return deferred
|
||||
}
|
||||
|
||||
private func engineExists(name: String, template: String) -> Bool {
|
||||
return profile.searchEngines.orderedEngines.contains { (engine) -> Bool in
|
||||
return engine.shortName == name || engine.searchTemplate == template
|
||||
}
|
||||
}
|
||||
|
||||
func getSearchTemplate(withString query: String) -> String? {
|
||||
let SearchTermComponent = "%s" //Placeholder in User Entered String
|
||||
let placeholder = "{searchTerms}" //Placeholder looked for when using Custom Search Engine in OpenSearch.swift
|
||||
|
||||
if query.contains(SearchTermComponent) {
|
||||
return query.replacingOccurrences(of: SearchTermComponent, with: placeholder)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
override func generateSettings() -> [SettingSection] {
|
||||
|
||||
func URLFromString(_ string: String?) -> URL? {
|
||||
guard let string = string else {
|
||||
return nil
|
||||
}
|
||||
return URL(string: string)
|
||||
}
|
||||
|
||||
let titleField = CustomSearchEngineTextView(placeholder: Strings.SettingsAddCustomEngineTitlePlaceholder, settingIsValid: { text in
|
||||
return text != nil && text != ""
|
||||
}, settingDidChange: {fieldText in
|
||||
guard let title = fieldText else {
|
||||
return
|
||||
}
|
||||
self.engineTitle = title
|
||||
})
|
||||
titleField.textField.accessibilityIdentifier = "customEngineTitle"
|
||||
|
||||
let urlField = CustomSearchEngineTextView(placeholder: Strings.SettingsAddCustomEngineURLPlaceholder, height: 133, settingIsValid: { text in
|
||||
//Can check url text text validity here.
|
||||
return true
|
||||
}, settingDidChange: {fieldText in
|
||||
self.urlString = fieldText
|
||||
})
|
||||
|
||||
urlField.textField.autocapitalizationType = .none
|
||||
urlField.textField.accessibilityIdentifier = "customEngineUrl"
|
||||
|
||||
let settings: [SettingSection] = [
|
||||
SettingSection(title: NSAttributedString(string: Strings.SettingsAddCustomEngineTitleLabel), children: [titleField]),
|
||||
SettingSection(title: NSAttributedString(string: Strings.SettingsAddCustomEngineURLLabel), footerTitle: NSAttributedString(string: "http://youtube.com/search?q=%s"), children: [urlField])
|
||||
]
|
||||
|
||||
self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .save, target: self, action: #selector(self.addCustomSearchEngine(_:)))
|
||||
self.navigationItem.rightBarButtonItem?.accessibilityIdentifier = "customEngineSaveButton"
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
func addCustomSearchEngine(_ nav: UINavigationController?) {
|
||||
self.view.endEditing(true)
|
||||
navigationItem.rightBarButtonItem?.isEnabled = false
|
||||
if let url = self.urlString {
|
||||
self.addSearchEngine(url, title: self.engineTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CustomSearchEngineTextView: Setting, UITextViewDelegate {
|
||||
|
||||
fileprivate let Padding: CGFloat = 8
|
||||
fileprivate let TextLabelHeight: CGFloat = 44
|
||||
fileprivate var TextFieldHeight: CGFloat = 44
|
||||
|
||||
fileprivate let defaultValue: String?
|
||||
fileprivate let placeholder: String
|
||||
fileprivate let settingDidChange: ((String?) -> Void)?
|
||||
fileprivate let settingIsValid: ((String?) -> Bool)?
|
||||
|
||||
let textField = UITextView()
|
||||
let placeholderLabel = UILabel()
|
||||
|
||||
init(defaultValue: String? = nil, placeholder: String, height: CGFloat = 44, settingIsValid isValueValid: ((String?) -> Bool)? = nil, settingDidChange: ((String?) -> Void)? = nil) {
|
||||
self.defaultValue = defaultValue
|
||||
self.TextFieldHeight = height
|
||||
self.settingDidChange = settingDidChange
|
||||
self.settingIsValid = isValueValid
|
||||
self.placeholder = placeholder
|
||||
textField.addSubview(placeholderLabel)
|
||||
super.init(cellHeight: TextFieldHeight)
|
||||
}
|
||||
|
||||
override func onConfigureCell(_ cell: UITableViewCell) {
|
||||
super.onConfigureCell(cell)
|
||||
if let id = accessibilityIdentifier {
|
||||
textField.accessibilityIdentifier = id + "TextField"
|
||||
}
|
||||
|
||||
placeholderLabel.adjustsFontSizeToFitWidth = true
|
||||
placeholderLabel.textColor = UIColor(red: 0.0, green: 0.0, blue: 0.0980392, alpha: 0.22)
|
||||
placeholderLabel.text = placeholder
|
||||
placeholderLabel.frame = CGRect(x: 0, y: 0, width: textField.frame.width, height: TextLabelHeight)
|
||||
textField.font = placeholderLabel.font
|
||||
|
||||
textField.textContainer.lineFragmentPadding = 0
|
||||
textField.keyboardType = .URL
|
||||
textField.autocorrectionType = .no
|
||||
textField.delegate = self
|
||||
cell.isUserInteractionEnabled = true
|
||||
cell.accessibilityTraits = UIAccessibilityTraitNone
|
||||
cell.contentView.addSubview(textField)
|
||||
cell.selectionStyle = .none
|
||||
|
||||
textField.snp.makeConstraints { make in
|
||||
make.height.equalTo(TextFieldHeight)
|
||||
make.left.right.equalTo(cell.contentView).inset(Padding)
|
||||
}
|
||||
}
|
||||
|
||||
override func onClick(_ navigationController: UINavigationController?) {
|
||||
textField.becomeFirstResponder()
|
||||
}
|
||||
|
||||
fileprivate func isValid(_ value: String?) -> Bool {
|
||||
guard let test = settingIsValid else {
|
||||
return true
|
||||
}
|
||||
return test(prepareValidValue(userInput: value))
|
||||
}
|
||||
|
||||
func prepareValidValue(userInput value: String?) -> String? {
|
||||
return value
|
||||
}
|
||||
|
||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||
placeholderLabel.isHidden = textField.text != ""
|
||||
}
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
placeholderLabel.isHidden = textField.text != ""
|
||||
settingDidChange?(textView.text)
|
||||
let color = isValid(textField.text) ? UIConstants.TableViewRowTextColor : UIConstants.DestructiveRed
|
||||
textField.textColor = color
|
||||
}
|
||||
|
||||
func textViewDidEndEditing(_ textView: UITextView) {
|
||||
placeholderLabel.isHidden = textField.text != ""
|
||||
settingDidChange?(textView.text)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import Foundation
|
||||
import Shared
|
||||
import SnapKit
|
||||
import UIKit
|
||||
import WebKit
|
||||
import SwiftyJSON
|
||||
|
||||
protocol FxAContentViewControllerDelegate: class {
|
||||
func contentViewControllerDidSignIn(_ viewController: FxAContentViewController, withFlags: FxALoginFlags)
|
||||
func contentViewControllerDidCancel(_ viewController: FxAContentViewController)
|
||||
}
|
||||
|
||||
/**
|
||||
* A controller that manages a single web view connected to the Firefox
|
||||
* Accounts (Desktop) Sync postMessage interface.
|
||||
*
|
||||
* The postMessage interface is not really documented, but it is simple
|
||||
* enough. I reverse engineered it from the Desktop Firefox code and the
|
||||
* fxa-content-server git repository.
|
||||
*/
|
||||
class FxAContentViewController: SettingsContentViewController, WKScriptMessageHandler {
|
||||
fileprivate enum RemoteCommand: String {
|
||||
case canLinkAccount = "can_link_account"
|
||||
case loaded = "loaded"
|
||||
case login = "login"
|
||||
case sessionStatus = "session_status"
|
||||
case signOut = "sign_out"
|
||||
}
|
||||
|
||||
weak var delegate: FxAContentViewControllerDelegate?
|
||||
|
||||
let profile: Profile
|
||||
|
||||
init(profile: Profile, fxaOptions: FxALaunchParams? = nil) {
|
||||
self.profile = profile
|
||||
|
||||
super.init(backgroundColor: UIColor(red: 242 / 255.0, green: 242 / 255.0, blue: 242 / 255.0, alpha: 1.0), title: NSAttributedString(string: "Firefox Accounts"))
|
||||
|
||||
if AppConstants.MOZ_FXA_DEEP_LINK_FORM_FILL {
|
||||
self.url = self.createFxAURLWith(fxaOptions, profile: profile)
|
||||
} else {
|
||||
self.url = profile.accountConfiguration.signInURL
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(FxAContentViewController.userDidVerify(_:)), name: NotificationFirefoxAccountVerified, object: nil)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
|
||||
if AppConstants.MOZ_SHOW_FXA_AVATAR {
|
||||
profile.getAccount()?.updateProfile()
|
||||
}
|
||||
|
||||
// If the FxAContentViewController was launched from a FxA deferred link
|
||||
// onboarding might not have been shown. Check to see if it needs to be
|
||||
// displayed and don't animate.
|
||||
if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
|
||||
appDelegate.browserViewController.presentIntroViewController(false, animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
override func makeWebView() -> WKWebView {
|
||||
// Inject our setup code after the page loads.
|
||||
let source = getJS()
|
||||
let userScript = WKUserScript(
|
||||
source: source,
|
||||
injectionTime: WKUserScriptInjectionTime.atDocumentEnd,
|
||||
forMainFrameOnly: true
|
||||
)
|
||||
|
||||
// Handle messages from the content server (via our user script).
|
||||
let contentController = WKUserContentController()
|
||||
contentController.addUserScript(userScript)
|
||||
contentController.add(LeakAvoider(delegate: self), name: "accountsCommandHandler")
|
||||
|
||||
let config = WKWebViewConfiguration()
|
||||
config.userContentController = contentController
|
||||
|
||||
let webView = WKWebView(
|
||||
frame: CGRect(x: 0, y: 0, width: 1, height: 1),
|
||||
configuration: config
|
||||
)
|
||||
webView.allowsLinkPreview = false
|
||||
webView.navigationDelegate = self
|
||||
webView.accessibilityLabel = NSLocalizedString("Web content", comment: "Accessibility label for the main web content view")
|
||||
|
||||
// Don't allow overscrolling.
|
||||
webView.scrollView.bounces = false
|
||||
return webView
|
||||
}
|
||||
|
||||
// Send a message to the content server.
|
||||
func injectData(_ type: String, content: [String: Any]) {
|
||||
let data = [
|
||||
"type": type,
|
||||
"content": content,
|
||||
] as [String: Any]
|
||||
let json = JSON(data).stringValue() ?? ""
|
||||
let script = "window.postMessage(\(json), '\(self.url.absoluteString)');"
|
||||
webView.evaluateJavaScript(script, completionHandler: nil)
|
||||
}
|
||||
|
||||
fileprivate func onCanLinkAccount(_ data: JSON) {
|
||||
// // We need to confirm a relink - see shouldAllowRelink for more
|
||||
// let ok = shouldAllowRelink(accountData.email);
|
||||
let ok = true
|
||||
injectData("message", content: ["status": "can_link_account", "data": ["ok": ok]])
|
||||
}
|
||||
|
||||
// We're not signed in to a Firefox Account at this time, which we signal by returning an error.
|
||||
fileprivate func onSessionStatus(_ data: JSON) {
|
||||
injectData("message", content: ["status": "error"])
|
||||
}
|
||||
|
||||
// We're not signed in to a Firefox Account at this time. We should never get a sign out message!
|
||||
fileprivate func onSignOut(_ data: JSON) {
|
||||
injectData("message", content: ["status": "error"])
|
||||
}
|
||||
|
||||
// The user has signed in to a Firefox Account. We're done!
|
||||
fileprivate func onLogin(_ data: JSON) {
|
||||
injectData("message", content: ["status": "login"])
|
||||
|
||||
let app = UIApplication.shared
|
||||
let helper = FxALoginHelper.sharedInstance
|
||||
helper.delegate = self
|
||||
helper.application(app, didReceiveAccountJSON: data)
|
||||
|
||||
if profile.hasAccount() {
|
||||
LeanPlumClient.shared.set(attributes: [LPAttributeKey.signedInSync: true])
|
||||
}
|
||||
|
||||
LeanPlumClient.shared.track(event: .signsInFxa)
|
||||
}
|
||||
|
||||
@objc fileprivate func userDidVerify(_ notification: Notification) {
|
||||
guard let account = profile.getAccount() else {
|
||||
return
|
||||
}
|
||||
// We can't verify against the actionNeeded of the account,
|
||||
// because of potential race conditions.
|
||||
// However, we restrict visibility of this method, and make sure
|
||||
// we only Notify via the FxALoginStateMachine.
|
||||
let flags = FxALoginFlags(pushEnabled: account.pushRegistration != nil,
|
||||
verified: true)
|
||||
LeanPlumClient.shared.set(attributes: [LPAttributeKey.signedInSync: true])
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.contentViewControllerDidSignIn(self, withFlags: flags)
|
||||
}
|
||||
}
|
||||
|
||||
// The content server page is ready to be shown.
|
||||
fileprivate func onLoaded() {
|
||||
self.timer?.invalidate()
|
||||
self.timer = nil
|
||||
self.isLoaded = true
|
||||
}
|
||||
|
||||
// Handle a message coming from the content server.
|
||||
func handleRemoteCommand(_ rawValue: String, data: JSON) {
|
||||
if let command = RemoteCommand(rawValue: rawValue) {
|
||||
if !isLoaded && command != .loaded {
|
||||
// Work around https://github.com/mozilla/fxa-content-server/issues/2137
|
||||
onLoaded()
|
||||
}
|
||||
|
||||
switch command {
|
||||
case .loaded:
|
||||
onLoaded()
|
||||
case .login:
|
||||
onLogin(data)
|
||||
case .canLinkAccount:
|
||||
onCanLinkAccount(data)
|
||||
case .sessionStatus:
|
||||
onSessionStatus(data)
|
||||
case .signOut:
|
||||
onSignOut(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch webkit messages originating from our child webview.
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
// Make sure we're communicating with a trusted page. That is, ensure the origin of the
|
||||
// message is the same as the origin of the URL we initially loaded in this web view.
|
||||
// Note that this exploit wouldn't be possible if we were using WebChannels; see
|
||||
// https://developer.mozilla.org/en-US/docs/Mozilla/JavaScript_code_modules/WebChannel.jsm
|
||||
let origin = message.frameInfo.securityOrigin
|
||||
guard origin.`protocol` == url.scheme && origin.host == url.host && origin.port == (url.port ?? 0) else {
|
||||
print("Ignoring message - \(origin) does not match expected origin: \(url.origin ?? "nil")")
|
||||
return
|
||||
}
|
||||
|
||||
if message.name == "accountsCommandHandler" {
|
||||
let body = JSON(message.body)
|
||||
let detail = body["detail"]
|
||||
handleRemoteCommand(detail["command"].stringValue, data: detail["data"])
|
||||
}
|
||||
}
|
||||
|
||||
// Configure the FxA signin url based on any passed options.
|
||||
public func createFxAURLWith(_ fxaOptions: FxALaunchParams?, profile: Profile) -> URL {
|
||||
let profileUrl = profile.accountConfiguration.signInURL
|
||||
|
||||
guard let launchParams = fxaOptions else {
|
||||
return profileUrl
|
||||
}
|
||||
|
||||
// Only append `signin`, `entrypoint` and `utm_*` parameters. Note that you can't
|
||||
// override the service and context params.
|
||||
var params = launchParams.query
|
||||
params.removeValue(forKey: "service")
|
||||
params.removeValue(forKey: "context")
|
||||
let queryURL = params.filter { $0.key == "signin" || $0.key == "entrypoint" || $0.key.range(of: "utm_") != nil }.map({
|
||||
return "\($0.key)=\($0.value)"
|
||||
}).joined(separator: "&")
|
||||
|
||||
return URL(string: "\(profileUrl)&\(queryURL)") ?? profileUrl
|
||||
}
|
||||
|
||||
fileprivate func getJS() -> String {
|
||||
let fileRoot = Bundle.main.path(forResource: "FxASignIn", ofType: "js")
|
||||
return (try! NSString(contentsOfFile: fileRoot!, encoding: String.Encoding.utf8.rawValue)) as String
|
||||
}
|
||||
|
||||
override func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
// Ignore for now.
|
||||
}
|
||||
|
||||
override func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
// Ignore for now.
|
||||
}
|
||||
}
|
||||
|
||||
extension FxAContentViewController: FxAPushLoginDelegate {
|
||||
func accountLoginDidSucceed(withFlags flags: FxALoginFlags) {
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.contentViewControllerDidSignIn(self, withFlags: flags)
|
||||
}
|
||||
}
|
||||
|
||||
func accountLoginDidFail() {
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.contentViewControllerDidCancel(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
LeakAvoider prevents leaks with WKUserContentController
|
||||
http://stackoverflow.com/questions/26383031/wkwebview-causes-my-view-controller-to-leak
|
||||
*/
|
||||
|
||||
class LeakAvoider: NSObject, WKScriptMessageHandler {
|
||||
weak var delegate: WKScriptMessageHandler?
|
||||
|
||||
init(delegate: WKScriptMessageHandler) {
|
||||
self.delegate = delegate
|
||||
super.init()
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
self.delegate?.userContentController(userContentController, didReceive: message)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import UIKit
|
||||
import Shared
|
||||
import SnapKit
|
||||
|
||||
private let log = Logger.browserLogger
|
||||
|
||||
class HomePageSettingsViewController: SettingsTableViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = Strings.SettingsHomePageTitle
|
||||
}
|
||||
|
||||
override func generateSettings() -> [SettingSection] {
|
||||
let prefs = profile.prefs
|
||||
let helper = HomePageHelper(prefs: prefs)
|
||||
func setHomePage(_ url: URL?) -> ((UINavigationController?) -> Void) {
|
||||
weak var tableView: UITableView? = self.tableView
|
||||
return { nav in
|
||||
helper.currentURL = url
|
||||
tableView?.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
func isHomePage(_ url: URL?) -> (() -> Bool) {
|
||||
return {
|
||||
return url?.isWebPage() ?? false
|
||||
}
|
||||
}
|
||||
|
||||
func URLFromString(_ string: String?) -> URL? {
|
||||
guard let string = string else {
|
||||
return nil
|
||||
}
|
||||
return URL(string: string)
|
||||
}
|
||||
|
||||
let currentTabURL = self.tabManager.selectedTab?.url?.displayURL
|
||||
let clipboardURL = URLFromString(UIPasteboard.general.string)
|
||||
|
||||
var basicSettings: [Setting] = [
|
||||
WebPageSetting(prefs: prefs,
|
||||
prefKey: HomePageConstants.HomePageURLPrefKey,
|
||||
placeholder: helper.defaultURLString ?? Strings.SettingsHomePagePlaceholder,
|
||||
accessibilityIdentifier: "HomePageSetting"),
|
||||
ButtonSetting(title: NSAttributedString(string: Strings.SettingsHomePageUseCurrentPage),
|
||||
accessibilityIdentifier: "UseCurrentTab",
|
||||
isEnabled: isHomePage(currentTabURL),
|
||||
onClick: setHomePage(currentTabURL)),
|
||||
ButtonSetting(title: NSAttributedString(string: Strings.SettingsHomePageUseCopiedLink),
|
||||
accessibilityIdentifier: "UseCopiedLink",
|
||||
isEnabled: isHomePage(clipboardURL),
|
||||
onClick: setHomePage(clipboardURL)),
|
||||
]
|
||||
|
||||
basicSettings += [
|
||||
ButtonSetting(title: NSAttributedString(string: Strings.SettingsHomePageClear),
|
||||
destructive: true,
|
||||
accessibilityIdentifier: "ClearHomePage",
|
||||
onClick: setHomePage(nil)),
|
||||
]
|
||||
|
||||
return [SettingSection(title: NSAttributedString(string: Strings.SettingsHomePageURLSectionTitle), children: basicSettings)]
|
||||
}
|
||||
}
|
||||
|
||||
class WebPageSetting: StringSetting {
|
||||
init(prefs: Prefs, prefKey: String, defaultValue: String? = nil, placeholder: String, accessibilityIdentifier: String, settingDidChange: ((String?) -> Void)? = nil) {
|
||||
super.init(prefs: prefs,
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
placeholder: placeholder,
|
||||
accessibilityIdentifier: accessibilityIdentifier,
|
||||
settingIsValid: WebPageSetting.isURLOrEmpty,
|
||||
settingDidChange: settingDidChange)
|
||||
textField.keyboardType = .URL
|
||||
textField.autocapitalizationType = .none
|
||||
textField.autocorrectionType = .no
|
||||
}
|
||||
|
||||
override func prepareValidValue(userInput value: String?) -> String? {
|
||||
guard let value = value else {
|
||||
return nil
|
||||
}
|
||||
return URIFixup.getURL(value)?.absoluteString
|
||||
}
|
||||
|
||||
static func isURLOrEmpty(_ string: String?) -> Bool {
|
||||
guard let string = string, !string.isEmpty else {
|
||||
return true
|
||||
}
|
||||
return URL(string: string)?.isWebPage() ?? false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,376 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import Foundation
|
||||
import Storage
|
||||
import Shared
|
||||
import SwiftKeychainWrapper
|
||||
|
||||
enum InfoItem: Int {
|
||||
case websiteItem = 0
|
||||
case usernameItem = 1
|
||||
case passwordItem = 2
|
||||
case lastModifiedSeparator = 3
|
||||
case deleteItem = 4
|
||||
|
||||
var indexPath: IndexPath {
|
||||
return IndexPath(row: rawValue, section: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private struct LoginDetailUX {
|
||||
static let InfoRowHeight: CGFloat = 58
|
||||
static let DeleteRowHeight: CGFloat = 44
|
||||
static let SeparatorHeight: CGFloat = 44
|
||||
}
|
||||
|
||||
class LoginDetailViewController: SensitiveViewController {
|
||||
|
||||
fileprivate let profile: Profile
|
||||
|
||||
fileprivate let tableView = UITableView()
|
||||
|
||||
fileprivate var login: Login {
|
||||
didSet {
|
||||
tableView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate var editingInfo: Bool = false {
|
||||
didSet {
|
||||
if editingInfo != oldValue {
|
||||
tableView.reloadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate let LoginCellIdentifier = "LoginCell"
|
||||
fileprivate let DefaultCellIdentifier = "DefaultCellIdentifier"
|
||||
fileprivate let SeparatorIdentifier = "SeparatorIdentifier"
|
||||
|
||||
// Used to temporarily store a reference to the cell the user is showing the menu controller for
|
||||
fileprivate var menuControllerCell: LoginTableViewCell?
|
||||
|
||||
fileprivate weak var websiteField: UITextField?
|
||||
fileprivate weak var usernameField: UITextField?
|
||||
fileprivate weak var passwordField: UITextField?
|
||||
|
||||
fileprivate var deleteAlert: UIAlertController?
|
||||
|
||||
weak var settingsDelegate: SettingsDelegate?
|
||||
|
||||
init(profile: Profile, login: Login) {
|
||||
self.login = login
|
||||
self.profile = profile
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(LoginDetailViewController.dismissAlertController), name: NSNotification.Name.UIApplicationDidEnterBackground, object: nil)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .edit, target: self, action: #selector(LoginDetailViewController.SELedit))
|
||||
|
||||
tableView.register(LoginTableViewCell.self, forCellReuseIdentifier: LoginCellIdentifier)
|
||||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: DefaultCellIdentifier)
|
||||
tableView.register(SettingsTableSectionHeaderFooterView.self, forHeaderFooterViewReuseIdentifier: SeparatorIdentifier)
|
||||
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(self.view)
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
tableView.separatorColor = UIConstants.TableViewSeparatorColor
|
||||
tableView.backgroundColor = UIConstants.TableViewHeaderBackgroundColor
|
||||
tableView.accessibilityIdentifier = "Login Detail List"
|
||||
tableView.delegate = self
|
||||
tableView.dataSource = self
|
||||
|
||||
// Add empty footer view to prevent seperators from being drawn past the last item.
|
||||
tableView.tableFooterView = UIView()
|
||||
|
||||
// Add a line on top of the table view so when the user pulls down it looks 'correct'.
|
||||
let topLine = UIView(frame: CGRect(origin: CGPoint.zero, size: CGSize(width: tableView.frame.width, height: 0.5)))
|
||||
topLine.backgroundColor = UIConstants.TableViewSeparatorColor
|
||||
tableView.tableHeaderView = topLine
|
||||
|
||||
// Normally UITableViewControllers handle responding to content inset changes from keyboard events when editing
|
||||
// but since we don't use the tableView's editing flag for editing we handle this ourselves.
|
||||
KeyboardHelper.defaultHelper.addDelegate(self)
|
||||
}
|
||||
|
||||
deinit {
|
||||
let notificationCenter = NotificationCenter.default
|
||||
notificationCenter.removeObserver(self, name: NotificationProfileDidFinishSyncing, object: nil)
|
||||
notificationCenter.removeObserver(self, name: NSNotification.Name.UIApplicationDidEnterBackground, object: nil)
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
// The following hacks are to prevent the default cell seperators from displaying. We want to
|
||||
// hide the default seperator for the website/last modified cells since the last modified cell
|
||||
// draws its own separators. The last item in the list draws its seperator full width.
|
||||
|
||||
// Prevent seperators from showing by pushing them off screen by the width of the cell
|
||||
let itemsToHideSeperators: [InfoItem] = [.passwordItem, .lastModifiedSeparator]
|
||||
itemsToHideSeperators.forEach { item in
|
||||
let cell = tableView.cellForRow(at: IndexPath(row: item.rawValue, section: 0))
|
||||
cell?.separatorInset = UIEdgeInsets(top: 0, left: cell?.bounds.width ?? 0, bottom: 0, right: 0)
|
||||
}
|
||||
|
||||
// Rows to display full width seperator
|
||||
let itemsToShowFullWidthSeperator: [InfoItem] = [.deleteItem]
|
||||
itemsToShowFullWidthSeperator.forEach { item in
|
||||
let cell = tableView.cellForRow(at: IndexPath(row: item.rawValue, section: 0))
|
||||
cell?.separatorInset = UIEdgeInsets.zero
|
||||
cell?.layoutMargins = UIEdgeInsets.zero
|
||||
cell?.preservesSuperviewLayoutMargins = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSource
|
||||
extension LoginDetailViewController: UITableViewDataSource {
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
switch InfoItem(rawValue: indexPath.row)! {
|
||||
case .usernameItem:
|
||||
let loginCell = dequeueLoginCellForIndexPath(indexPath)
|
||||
loginCell.style = .noIconAndBothLabels
|
||||
loginCell.highlightedLabelTitle = NSLocalizedString("username", tableName: "LoginManager", comment: "Label displayed above the username row in Login Detail View.")
|
||||
loginCell.descriptionLabel.text = login.username
|
||||
loginCell.descriptionLabel.keyboardType = .emailAddress
|
||||
loginCell.descriptionLabel.returnKeyType = .next
|
||||
loginCell.editingDescription = editingInfo
|
||||
usernameField = loginCell.descriptionLabel
|
||||
usernameField?.accessibilityIdentifier = "usernameField"
|
||||
return loginCell
|
||||
|
||||
case .passwordItem:
|
||||
let loginCell = dequeueLoginCellForIndexPath(indexPath)
|
||||
loginCell.style = .noIconAndBothLabels
|
||||
loginCell.highlightedLabelTitle = NSLocalizedString("password", tableName: "LoginManager", comment: "Label displayed above the password row in Login Detail View.")
|
||||
loginCell.descriptionLabel.text = login.password
|
||||
loginCell.descriptionLabel.returnKeyType = .default
|
||||
loginCell.displayDescriptionAsPassword = true
|
||||
loginCell.editingDescription = editingInfo
|
||||
passwordField = loginCell.descriptionLabel
|
||||
passwordField?.accessibilityIdentifier = "passwordField"
|
||||
return loginCell
|
||||
|
||||
case .websiteItem:
|
||||
let loginCell = dequeueLoginCellForIndexPath(indexPath)
|
||||
loginCell.style = .noIconAndBothLabels
|
||||
loginCell.highlightedLabelTitle = NSLocalizedString("website", tableName: "LoginManager", comment: "Label displayed above the website row in Login Detail View.")
|
||||
loginCell.descriptionLabel.text = login.hostname
|
||||
websiteField = loginCell.descriptionLabel
|
||||
websiteField?.accessibilityIdentifier = "websiteField"
|
||||
return loginCell
|
||||
|
||||
case .lastModifiedSeparator:
|
||||
let footer = tableView.dequeueReusableHeaderFooterView(withIdentifier: SeparatorIdentifier) as! SettingsTableSectionHeaderFooterView
|
||||
footer.titleAlignment = .top
|
||||
let lastModified = NSLocalizedString("Last modified %@", tableName: "LoginManager", comment: "Footer label describing when the current login was last modified with the timestamp as the parameter.")
|
||||
let formattedLabel = String(format: lastModified, Date.fromMicrosecondTimestamp(login.timePasswordChanged).toRelativeTimeString())
|
||||
footer.titleLabel.text = formattedLabel
|
||||
let cell = wrapFooter(footer, withCellFromTableView: tableView, atIndexPath: indexPath)
|
||||
return cell
|
||||
|
||||
case .deleteItem:
|
||||
let deleteCell = tableView.dequeueReusableCell(withIdentifier: DefaultCellIdentifier, for: indexPath)
|
||||
deleteCell.textLabel?.text = NSLocalizedString("Delete", tableName: "LoginManager", comment: "Label for the button used to delete the current login.")
|
||||
deleteCell.textLabel?.textAlignment = NSTextAlignment.center
|
||||
deleteCell.textLabel?.textColor = UIConstants.DestructiveRed
|
||||
deleteCell.accessibilityTraits = UIAccessibilityTraitButton
|
||||
return deleteCell
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func dequeueLoginCellForIndexPath(_ indexPath: IndexPath) -> LoginTableViewCell {
|
||||
let loginCell = tableView.dequeueReusableCell(withIdentifier: LoginCellIdentifier, for: indexPath) as! LoginTableViewCell
|
||||
loginCell.selectionStyle = .none
|
||||
loginCell.delegate = self
|
||||
return loginCell
|
||||
}
|
||||
|
||||
fileprivate func wrapFooter(_ footer: UITableViewHeaderFooterView, withCellFromTableView tableView: UITableView, atIndexPath indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: DefaultCellIdentifier, for: indexPath)
|
||||
cell.selectionStyle = .none
|
||||
cell.addSubview(footer)
|
||||
footer.snp.makeConstraints { make in
|
||||
make.edges.equalTo(cell)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return 5
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
extension LoginDetailViewController: UITableViewDelegate {
|
||||
private func showMenuOnSingleTap(forIndexPath indexPath: IndexPath) {
|
||||
guard let item = InfoItem(rawValue: indexPath.row) else { return }
|
||||
if ![InfoItem.passwordItem, InfoItem.websiteItem, InfoItem.usernameItem].contains(item) {
|
||||
return
|
||||
}
|
||||
|
||||
guard let cell = tableView.cellForRow(at: indexPath) as? LoginTableViewCell else { return }
|
||||
|
||||
cell.becomeFirstResponder()
|
||||
|
||||
let menu = UIMenuController.shared
|
||||
menu.setTargetRect(cell.frame, in: self.tableView)
|
||||
menu.setMenuVisible(true, animated: true)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
if indexPath == InfoItem.deleteItem.indexPath {
|
||||
deleteLogin()
|
||||
} else if !editingInfo {
|
||||
showMenuOnSingleTap(forIndexPath: indexPath)
|
||||
}
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
switch InfoItem(rawValue: indexPath.row)! {
|
||||
case .usernameItem, .passwordItem, .websiteItem:
|
||||
return LoginDetailUX.InfoRowHeight
|
||||
case .lastModifiedSeparator:
|
||||
return LoginDetailUX.SeparatorHeight
|
||||
case .deleteItem:
|
||||
return LoginDetailUX.DeleteRowHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - KeyboardHelperDelegate
|
||||
extension LoginDetailViewController: KeyboardHelperDelegate {
|
||||
|
||||
func keyboardHelper(_ keyboardHelper: KeyboardHelper, keyboardWillShowWithState state: KeyboardState) {
|
||||
let coveredHeight = state.intersectionHeightForView(tableView)
|
||||
tableView.contentInset.bottom = coveredHeight
|
||||
}
|
||||
|
||||
func keyboardHelper(_ keyboardHelper: KeyboardHelper, keyboardDidShowWithState state: KeyboardState) {
|
||||
}
|
||||
|
||||
func keyboardHelper(_ keyboardHelper: KeyboardHelper, keyboardWillHideWithState state: KeyboardState) {
|
||||
tableView.contentInset.bottom = 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selectors
|
||||
extension LoginDetailViewController {
|
||||
|
||||
@objc func dismissAlertController() {
|
||||
self.deleteAlert?.dismiss(animated: false, completion: nil)
|
||||
}
|
||||
|
||||
func deleteLogin() {
|
||||
profile.logins.hasSyncedLogins().uponQueue(DispatchQueue.main) { yes in
|
||||
self.deleteAlert = UIAlertController.deleteLoginAlertWithDeleteCallback({ [unowned self] _ in
|
||||
self.profile.logins.removeLoginByGUID(self.login.guid).uponQueue(DispatchQueue.main) { _ in
|
||||
_ = self.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
}, hasSyncedLogins: yes.successValue ?? true)
|
||||
|
||||
self.present(self.deleteAlert!, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func SELonProfileDidFinishSyncing() {
|
||||
// Reload details after syncing.
|
||||
profile.logins.getLoginDataForGUID(login.guid).uponQueue(DispatchQueue.main) { result in
|
||||
if let syncedLogin = result.successValue {
|
||||
self.login = syncedLogin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func SELedit() {
|
||||
editingInfo = true
|
||||
|
||||
let cell = tableView.cellForRow(at: InfoItem.usernameItem.indexPath) as! LoginTableViewCell
|
||||
cell.descriptionLabel.becomeFirstResponder()
|
||||
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(LoginDetailViewController.SELdoneEditing))
|
||||
}
|
||||
|
||||
func SELdoneEditing() {
|
||||
editingInfo = false
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .edit, target: self, action: #selector(LoginDetailViewController.SELedit))
|
||||
|
||||
defer {
|
||||
// Required to get UI to reload with changed state
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
// We only care to update if we changed something
|
||||
guard let username = usernameField?.text,
|
||||
let password = passwordField?.text, username != login.username || password != login.password else {
|
||||
return
|
||||
}
|
||||
|
||||
// Keep a copy of the old data in case we fail and need to revert back
|
||||
let oldPassword = login.password
|
||||
let oldUsername = login.username
|
||||
login.update(password: password, username: username)
|
||||
|
||||
if login.isValid.isSuccess {
|
||||
profile.logins.updateLoginByGUID(login.guid, new: login, significant: true)
|
||||
} else if let oldUsername = oldUsername {
|
||||
login.update(password: oldPassword, username: oldUsername)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cell Delegate
|
||||
extension LoginDetailViewController: LoginTableViewCellDelegate {
|
||||
|
||||
fileprivate func cellForItem(_ item: InfoItem) -> LoginTableViewCell? {
|
||||
return tableView.cellForRow(at: item.indexPath) as? LoginTableViewCell
|
||||
}
|
||||
|
||||
func didSelectOpenAndFillForCell(_ cell: LoginTableViewCell) {
|
||||
guard let url = (self.login.formSubmitURL?.asURL ?? self.login.hostname.asURL) else {
|
||||
return
|
||||
}
|
||||
|
||||
navigationController?.dismiss(animated: true, completion: {
|
||||
self.settingsDelegate?.settingsOpenURLInNewTab(url)
|
||||
})
|
||||
}
|
||||
|
||||
func shouldReturnAfterEditingDescription(_ cell: LoginTableViewCell) -> Bool {
|
||||
let usernameCell = cellForItem(.usernameItem)
|
||||
let passwordCell = cellForItem(.passwordItem)
|
||||
|
||||
if cell == usernameCell {
|
||||
passwordCell?.descriptionLabel.becomeFirstResponder()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func infoItemForCell(_ cell: LoginTableViewCell) -> InfoItem? {
|
||||
if let index = tableView.indexPath(for: cell),
|
||||
let item = InfoItem(rawValue: index.row) {
|
||||
return item
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import Foundation
|
||||
import Shared
|
||||
|
||||
/// Screen presented to the user when selecting the page that is displayed when the user goes to a new tab.
|
||||
class NewTabChoiceViewController: UITableViewController {
|
||||
|
||||
let newTabOptions: [NewTabPage] = [.blankPage, .topSites, .bookmarks, .history, .homePage]
|
||||
|
||||
let prefs: Prefs
|
||||
var currentChoice: NewTabPage!
|
||||
var hasHomePage: Bool!
|
||||
|
||||
fileprivate let BasicCheckmarkCell = "BasicCheckmarkCell"
|
||||
fileprivate var authenticationInfo: AuthenticationKeychainInfo?
|
||||
|
||||
init(prefs: Prefs) {
|
||||
self.prefs = prefs
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = Strings.SettingsNewTabTitle
|
||||
|
||||
tableView.accessibilityIdentifier = "NewTabPage.Setting.Options"
|
||||
|
||||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: BasicCheckmarkCell)
|
||||
tableView.backgroundColor = UIConstants.TableViewHeaderBackgroundColor
|
||||
|
||||
let headerFooterFrame = CGRect(origin: CGPoint.zero, size: CGSize(width: self.view.frame.width, height: UIConstants.TableViewHeaderFooterHeight))
|
||||
let headerView = SettingsTableSectionHeaderFooterView(frame: headerFooterFrame)
|
||||
headerView.showTopBorder = false
|
||||
headerView.showBottomBorder = true
|
||||
|
||||
let footerView = SettingsTableSectionHeaderFooterView(frame: headerFooterFrame)
|
||||
footerView.showTopBorder = true
|
||||
footerView.showBottomBorder = false
|
||||
|
||||
tableView.tableHeaderView = headerView
|
||||
tableView.tableFooterView = footerView
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
self.currentChoice = NewTabAccessors.getNewTabPage(prefs)
|
||||
self.hasHomePage = HomePageAccessors.getHomePage(prefs) != nil
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
self.prefs.setString(currentChoice.rawValue, forKey: NewTabAccessors.PrefKey)
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: BasicCheckmarkCell, for: indexPath)
|
||||
|
||||
let option = newTabOptions[indexPath.row]
|
||||
let enabled = (option != .homePage) || hasHomePage
|
||||
|
||||
cell.accessoryType = (currentChoice == option) ? .checkmark : .none
|
||||
cell.textLabel?.attributedText = NSAttributedString.tableRowTitle(option.settingTitle, enabled: enabled)
|
||||
cell.isUserInteractionEnabled = enabled
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return newTabOptions.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
currentChoice = newTabOptions[indexPath.row]
|
||||
tableView.reloadData()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import Foundation
|
||||
import Shared
|
||||
|
||||
class CurrentTabSetting: Setting {
|
||||
let profile: Profile
|
||||
|
||||
override var accessoryType: UITableViewCellAccessoryType { return .disclosureIndicator }
|
||||
|
||||
override var style: UITableViewCellStyle { return .value1 }
|
||||
|
||||
override var accessibilityIdentifier: String? { return "NewTabOption" }
|
||||
|
||||
init(profile: Profile) {
|
||||
self.profile = profile
|
||||
super.init(title: NSAttributedString(string: NewTabAccessors.getNewTabPage(profile.prefs).settingTitle, attributes: [NSForegroundColorAttributeName: UIConstants.TableViewRowTextColor]))
|
||||
}
|
||||
|
||||
override func onClick(_ navigationController: UINavigationController?) {
|
||||
let viewController = NewTabChoiceViewController(prefs: profile.prefs)
|
||||
navigationController?.pushViewController(viewController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
class NewTabContentSettingsViewController: SettingsTableViewController {
|
||||
|
||||
init() {
|
||||
super.init(style: .grouped)
|
||||
|
||||
self.title = Strings.SettingsNewTabTitle
|
||||
hasSectionSeparatorLine = false
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func generateSettings() -> [SettingSection] {
|
||||
let tabSetting = CurrentTabSetting(profile: profile)
|
||||
let firstSection = SettingSection(title: NSAttributedString(string: Strings.SettingsNewTabSectionName), footerTitle: nil, children: [tabSetting])
|
||||
|
||||
let isPocketEnabledDefault = Pocket.IslocaleSupported(Locale.current.identifier)
|
||||
let pocketSetting = BoolSetting(prefs: profile.prefs, prefKey: PrefsKeys.ASPocketStoriesVisible, defaultValue: isPocketEnabledDefault, attributedTitleText: NSAttributedString(string: Strings.SettingsNewTabPocket))
|
||||
let bookmarks = BoolSetting(prefs: profile.prefs, prefKey: PrefsKeys.ASBookmarkHighlightsVisible, defaultValue: true, attributedTitleText: NSAttributedString(string: Strings.SettingsNewTabHighlightsBookmarks))
|
||||
let history = BoolSetting(prefs: profile.prefs, prefKey: PrefsKeys.ASRecentHighlightsVisible, defaultValue: true, attributedTitleText: NSAttributedString(string: Strings.SettingsNewTabHiglightsHistory))
|
||||
let options = AppConstants.MOZ_POCKET_STORIES ? [pocketSetting, bookmarks, history] : [bookmarks, history]
|
||||
let secondSection = SettingSection(title: NSAttributedString(string: Strings.SettingsNewTabASTitle), footerTitle: nil, children: options)
|
||||
|
||||
return [firstSection, secondSection]
|
||||
}
|
||||
}
|
||||
|
||||
47
mobile/ios/Client/Frontend/Settings/SearchEnginePicker.swift
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import UIKit
|
||||
|
||||
class SearchEnginePicker: UITableViewController {
|
||||
weak var delegate: SearchEnginePickerDelegate?
|
||||
var engines: [OpenSearchEngine]!
|
||||
var selectedSearchEngineName: String?
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
navigationItem.title = NSLocalizedString("Default Search Engine", comment: "Title for default search engine picker.")
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Cancel", comment: "Label for Cancel button"), style: .plain, target: self, action: #selector(SearchEnginePicker.cancel))
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return engines.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let engine = engines[indexPath.item]
|
||||
let cell = UITableViewCell(style: UITableViewCellStyle.default, reuseIdentifier: nil)
|
||||
cell.textLabel?.text = engine.shortName
|
||||
cell.imageView?.image = engine.image.createScaled(CGSize(width: OpenSearchEngine.PreferredIconSize, height: OpenSearchEngine.PreferredIconSize))
|
||||
if engine.shortName == selectedSearchEngineName {
|
||||
cell.accessoryType = UITableViewCellAccessoryType.checkmark
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
let engine = engines[indexPath.item]
|
||||
delegate?.searchEnginePicker(self, didSelectSearchEngine: engine)
|
||||
tableView.cellForRow(at: indexPath)?.accessoryType = UITableViewCellAccessoryType.checkmark
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
|
||||
tableView.cellForRow(at: indexPath)?.accessoryType = UITableViewCellAccessoryType.none
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
delegate?.searchEnginePicker(self, didSelectSearchEngine: nil)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,331 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import UIKit
|
||||
import SDWebImage
|
||||
import Shared
|
||||
|
||||
protocol SearchEnginePickerDelegate: class {
|
||||
func searchEnginePicker(_ searchEnginePicker: SearchEnginePicker?, didSelectSearchEngine engine: OpenSearchEngine?)
|
||||
}
|
||||
|
||||
class SearchSettingsTableViewController: UITableViewController {
|
||||
fileprivate let SectionDefault = 0
|
||||
fileprivate let ItemDefaultEngine = 0
|
||||
fileprivate let ItemDefaultSuggestions = 1
|
||||
fileprivate let ItemAddCustomSearch = 2
|
||||
fileprivate let NumberOfItemsInSectionDefault = 2
|
||||
fileprivate let SectionOrder = 1
|
||||
fileprivate let NumberOfSections = 2
|
||||
fileprivate let IconSize = CGSize(width: OpenSearchEngine.PreferredIconSize, height: OpenSearchEngine.PreferredIconSize)
|
||||
fileprivate let SectionHeaderIdentifier = "SectionHeaderIdentifier"
|
||||
|
||||
fileprivate var showDeletion = false
|
||||
|
||||
var profile: Profile?
|
||||
var tabManager: TabManager?
|
||||
|
||||
fileprivate var isEditable: Bool {
|
||||
// If the default engine is a custom one, make sure we have more than one since we can't edit the default.
|
||||
// Otherwise, enable editing if we have at least one custom engine.
|
||||
let customEngineCount = model.orderedEngines.filter({$0.isCustomEngine}).count
|
||||
return model.defaultEngine.isCustomEngine ? customEngineCount > 1 : customEngineCount > 0
|
||||
}
|
||||
|
||||
var model: SearchEngines!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
navigationItem.title = NSLocalizedString("Search", comment: "Navigation title for search settings.")
|
||||
|
||||
// To allow re-ordering the list of search engines at all times.
|
||||
tableView.isEditing = true
|
||||
// So that we push the default search engine controller on selection.
|
||||
tableView.allowsSelectionDuringEditing = true
|
||||
|
||||
tableView.register(SettingsTableSectionHeaderFooterView.self, forHeaderFooterViewReuseIdentifier: SectionHeaderIdentifier)
|
||||
|
||||
// Insert Done button if being presented outside of the Settings Nav stack
|
||||
if !(self.navigationController is SettingsNavigationController) {
|
||||
self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: Strings.SettingsSearchDoneButton, style: .done, target: self, action: #selector(self.dismissAnimated))
|
||||
}
|
||||
|
||||
let footer = SettingsTableSectionHeaderFooterView(frame: CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 44))
|
||||
footer.showBottomBorder = false
|
||||
tableView.tableFooterView = footer
|
||||
|
||||
tableView.separatorColor = UIConstants.TableViewSeparatorColor
|
||||
tableView.backgroundColor = UIConstants.TableViewHeaderBackgroundColor
|
||||
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(title: Strings.SettingsSearchEditButton, style: .plain, target: self,
|
||||
action: #selector(SearchSettingsTableViewController.beginEditing))
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
// Only show the Edit button if custom search engines are in the list.
|
||||
// Otherwise, there is nothing to delete.
|
||||
navigationItem.rightBarButtonItem?.isEnabled = isEditable
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
setEditing(false, animated: false)
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
var cell: UITableViewCell!
|
||||
var engine: OpenSearchEngine!
|
||||
|
||||
if indexPath.section == SectionDefault {
|
||||
switch indexPath.item {
|
||||
case ItemDefaultEngine:
|
||||
engine = model.defaultEngine
|
||||
cell = UITableViewCell(style: UITableViewCellStyle.default, reuseIdentifier: nil)
|
||||
cell.editingAccessoryType = UITableViewCellAccessoryType.disclosureIndicator
|
||||
cell.accessibilityLabel = NSLocalizedString("Default Search Engine", comment: "Accessibility label for default search engine setting.")
|
||||
cell.accessibilityValue = engine.shortName
|
||||
cell.textLabel?.text = engine.shortName
|
||||
cell.imageView?.image = engine.image.createScaled(IconSize)
|
||||
cell.imageView?.layer.cornerRadius = 4
|
||||
cell.imageView?.layer.masksToBounds = true
|
||||
case ItemDefaultSuggestions:
|
||||
cell = UITableViewCell(style: UITableViewCellStyle.default, reuseIdentifier: nil)
|
||||
cell.textLabel?.text = NSLocalizedString("Show Search Suggestions", comment: "Label for show search suggestions setting.")
|
||||
let toggle = UISwitch()
|
||||
toggle.onTintColor = UIConstants.ControlTintColor
|
||||
toggle.addTarget(self, action: #selector(SearchSettingsTableViewController.didToggleSearchSuggestions(_:)), for: UIControlEvents.valueChanged)
|
||||
toggle.isOn = model.shouldShowSearchSuggestions
|
||||
cell.editingAccessoryView = toggle
|
||||
cell.selectionStyle = .none
|
||||
default:
|
||||
// Should not happen.
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// The default engine is not a quick search engine.
|
||||
let index = indexPath.item + 1
|
||||
if index < model.orderedEngines.count {
|
||||
engine = model.orderedEngines[index]
|
||||
|
||||
cell = UITableViewCell(style: UITableViewCellStyle.default, reuseIdentifier: nil)
|
||||
cell.showsReorderControl = true
|
||||
|
||||
let toggle = UISwitch()
|
||||
toggle.onTintColor = UIConstants.ControlTintColor
|
||||
// This is an easy way to get from the toggle control to the corresponding index.
|
||||
toggle.tag = index
|
||||
toggle.addTarget(self, action: #selector(SearchSettingsTableViewController.didToggleEngine(_:)), for: UIControlEvents.valueChanged)
|
||||
toggle.isOn = model.isEngineEnabled(engine)
|
||||
|
||||
cell.editingAccessoryView = toggle
|
||||
cell.textLabel?.text = engine.shortName
|
||||
cell.textLabel?.adjustsFontSizeToFitWidth = true
|
||||
cell.textLabel?.minimumScaleFactor = 0.5
|
||||
cell.imageView?.image = engine.image.createScaled(IconSize)
|
||||
cell.imageView?.layer.cornerRadius = 4
|
||||
cell.imageView?.layer.masksToBounds = true
|
||||
cell.selectionStyle = .none
|
||||
} else {
|
||||
cell = UITableViewCell(style: UITableViewCellStyle.default, reuseIdentifier: nil)
|
||||
cell.editingAccessoryType = UITableViewCellAccessoryType.disclosureIndicator
|
||||
cell.accessibilityLabel = Strings.SettingsAddCustomEngineTitle
|
||||
cell.accessibilityIdentifier = "customEngineViewButton"
|
||||
cell.textLabel?.text = Strings.SettingsAddCustomEngine
|
||||
}
|
||||
}
|
||||
|
||||
// So that the seperator line goes all the way to the left edge.
|
||||
cell.separatorInset = UIEdgeInsets.zero
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return NumberOfSections
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
if section == SectionDefault {
|
||||
return NumberOfItemsInSectionDefault
|
||||
} else {
|
||||
// The first engine -- the default engine -- is not shown in the quick search engine list.
|
||||
// But the option to add Custom Engine is.
|
||||
return AppConstants.MOZ_CUSTOM_SEARCH_ENGINE ? model.orderedEngines.count : model.orderedEngines.count - 1
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
|
||||
if indexPath.section == SectionDefault && indexPath.item == ItemDefaultEngine {
|
||||
let searchEnginePicker = SearchEnginePicker()
|
||||
// Order alphabetically, so that picker is always consistently ordered.
|
||||
// Every engine is a valid choice for the default engine, even the current default engine.
|
||||
searchEnginePicker.engines = model.orderedEngines.sorted { e, f in e.shortName < f.shortName }
|
||||
searchEnginePicker.delegate = self
|
||||
searchEnginePicker.selectedSearchEngineName = model.defaultEngine.shortName
|
||||
navigationController?.pushViewController(searchEnginePicker, animated: true)
|
||||
} else if indexPath.item + 1 == model.orderedEngines.count {
|
||||
let customSearchEngineForm = CustomSearchViewController()
|
||||
customSearchEngineForm.profile = self.profile
|
||||
customSearchEngineForm.successCallback = {
|
||||
guard let window = self.view.window else { return }
|
||||
SimpleToast().showAlertWithText(Strings.ThirdPartySearchEngineAdded, bottomContainer: window)
|
||||
}
|
||||
navigationController?.pushViewController(customSearchEngineForm, animated: true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Don't show delete button on the left.
|
||||
override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCellEditingStyle {
|
||||
if indexPath.section == SectionDefault || indexPath.item + 1 == model.orderedEngines.count {
|
||||
return UITableViewCellEditingStyle.none
|
||||
}
|
||||
|
||||
let index = indexPath.item + 1
|
||||
let engine = model.orderedEngines[index]
|
||||
return (self.showDeletion && engine.isCustomEngine) ? .delete : .none
|
||||
}
|
||||
|
||||
// Don't reserve space for the delete button on the left.
|
||||
override func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Hide a thin vertical line that iOS renders between the accessoryView and the reordering control.
|
||||
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
if cell.isEditing {
|
||||
for v in cell.subviews where v.frame.width == 1.0 {
|
||||
v.backgroundColor = UIColor.clear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
||||
return 44
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: SectionHeaderIdentifier) as! SettingsTableSectionHeaderFooterView
|
||||
var sectionTitle: String
|
||||
if section == SectionDefault {
|
||||
sectionTitle = NSLocalizedString("Default Search Engine", comment: "Title for default search engine settings section.")
|
||||
} else {
|
||||
sectionTitle = NSLocalizedString("Quick-Search Engines", comment: "Title for quick-search engines settings section.")
|
||||
}
|
||||
headerView.titleLabel.text = sectionTitle
|
||||
|
||||
return headerView
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
|
||||
if indexPath.section == SectionDefault || indexPath.item + 1 == model.orderedEngines.count {
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, moveRowAt indexPath: IndexPath, to newIndexPath: IndexPath) {
|
||||
// The first engine (default engine) is not shown in the list, so the indices are off-by-1.
|
||||
let index = indexPath.item + 1
|
||||
let newIndex = newIndexPath.item + 1
|
||||
let engine = model.orderedEngines.remove(at: index)
|
||||
model.orderedEngines.insert(engine, at: newIndex)
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
// Snap to first or last row of the list of engines.
|
||||
override func tableView(_ tableView: UITableView, targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, toProposedIndexPath proposedDestinationIndexPath: IndexPath) -> IndexPath {
|
||||
// You can't drag or drop on the default engine.
|
||||
if sourceIndexPath.section == SectionDefault || proposedDestinationIndexPath.section == SectionDefault {
|
||||
return sourceIndexPath
|
||||
}
|
||||
|
||||
//Can't drag/drop over "Add Custom Engine button"
|
||||
if sourceIndexPath.item + 1 == model.orderedEngines.count || proposedDestinationIndexPath.item + 1 == model.orderedEngines.count {
|
||||
return sourceIndexPath
|
||||
}
|
||||
|
||||
if sourceIndexPath.section != proposedDestinationIndexPath.section {
|
||||
var row = 0
|
||||
if sourceIndexPath.section < proposedDestinationIndexPath.section {
|
||||
row = tableView.numberOfRows(inSection: sourceIndexPath.section) - 1
|
||||
}
|
||||
return IndexPath(row: row, section: sourceIndexPath.section)
|
||||
}
|
||||
return proposedDestinationIndexPath
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
|
||||
if editingStyle == .delete {
|
||||
let index = indexPath.item + 1
|
||||
let engine = model.orderedEngines[index]
|
||||
model.deleteCustomEngine(engine)
|
||||
tableView.deleteRows(at: [indexPath], with: .right)
|
||||
|
||||
// End editing if we are no longer edit since we've deleted all editable cells.
|
||||
if !isEditable {
|
||||
finishEditing()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func setEditing(_ editing: Bool, animated: Bool) {
|
||||
showDeletion = editing
|
||||
UIView.performWithoutAnimation {
|
||||
self.navigationItem.rightBarButtonItem?.title = editing ? Strings.SettingsSearchDoneButton : Strings.SettingsSearchEditButton
|
||||
}
|
||||
navigationItem.rightBarButtonItem?.isEnabled = isEditable
|
||||
navigationItem.rightBarButtonItem?.action = editing ?
|
||||
#selector(SearchSettingsTableViewController.finishEditing) : #selector(SearchSettingsTableViewController.beginEditing)
|
||||
tableView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selectors
|
||||
extension SearchSettingsTableViewController {
|
||||
func didToggleEngine(_ toggle: UISwitch) {
|
||||
let engine = model.orderedEngines[toggle.tag] // The tag is 1-based.
|
||||
if toggle.isOn {
|
||||
model.enableEngine(engine)
|
||||
} else {
|
||||
model.disableEngine(engine)
|
||||
}
|
||||
}
|
||||
|
||||
func didToggleSearchSuggestions(_ toggle: UISwitch) {
|
||||
// Setting the value in settings dismisses any opt-in.
|
||||
model.shouldShowSearchSuggestions = toggle.isOn
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
_ = navigationController?.popViewController(animated: true)
|
||||
}
|
||||
|
||||
func dismissAnimated() {
|
||||
self.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func beginEditing() {
|
||||
setEditing(true, animated: false)
|
||||
}
|
||||
|
||||
func finishEditing() {
|
||||
setEditing(false, animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchSettingsTableViewController: SearchEnginePickerDelegate {
|
||||
func searchEnginePicker(_ searchEnginePicker: SearchEnginePicker?, didSelectSearchEngine searchEngine: OpenSearchEngine?) {
|
||||
if let engine = searchEngine {
|
||||
model.defaultEngine = engine
|
||||
self.tableView.reloadData()
|
||||
UnifiedTelemetry.recordEvent(category: .action, method: .change, object: .setting, value: "defaultSearchEngine", extras: ["to": engine.engineID ?? "custom"])
|
||||
}
|
||||
_ = navigationController?.popViewController(animated: true)
|
||||
}
|
||||
}
|
||||
BIN
mobile/ios/Client/Frontend/Settings/Settings.xcassets/AmberCaution.imageset/AmberCaution.png
vendored
Normal file
|
After Width: | Height: | Size: 445 B |
BIN
mobile/ios/Client/Frontend/Settings/Settings.xcassets/AmberCaution.imageset/AmberCaution@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 890 B |
BIN
mobile/ios/Client/Frontend/Settings/Settings.xcassets/AmberCaution.imageset/AmberCaution@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
23
mobile/ios/Client/Frontend/Settings/Settings.xcassets/AmberCaution.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "AmberCaution.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "AmberCaution@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "AmberCaution@3x.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
23
mobile/ios/Client/Frontend/Settings/Settings.xcassets/FxA-Default.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "FxA-Default.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "FxA-Default@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "FxA-Default@3x.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
BIN
mobile/ios/Client/Frontend/Settings/Settings.xcassets/FxA-Default.imageset/FxA-Default.png
vendored
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
mobile/ios/Client/Frontend/Settings/Settings.xcassets/FxA-Default.imageset/FxA-Default@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
mobile/ios/Client/Frontend/Settings/Settings.xcassets/FxA-Default.imageset/FxA-Default@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 21 KiB |
23
mobile/ios/Client/Frontend/Settings/Settings.xcassets/FxA-Sync-Blue.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "FxA-Sync-Blue.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "FxA-Sync-Blue@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "FxA-Sync-Blue@3x.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
BIN
mobile/ios/Client/Frontend/Settings/Settings.xcassets/FxA-Sync-Blue.imageset/FxA-Sync-Blue.png
vendored
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
mobile/ios/Client/Frontend/Settings/Settings.xcassets/FxA-Sync-Blue.imageset/FxA-Sync-Blue@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
mobile/ios/Client/Frontend/Settings/Settings.xcassets/FxA-Sync-Blue.imageset/FxA-Sync-Blue@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 16 KiB |
23
mobile/ios/Client/Frontend/Settings/Settings.xcassets/FxA-Sync.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "FxA-Sync.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "FxA-Sync@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "FxA-Sync@3x.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
BIN
mobile/ios/Client/Frontend/Settings/Settings.xcassets/FxA-Sync.imageset/FxA-Sync.png
vendored
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
mobile/ios/Client/Frontend/Settings/Settings.xcassets/FxA-Sync.imageset/FxA-Sync@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
mobile/ios/Client/Frontend/Settings/Settings.xcassets/FxA-Sync.imageset/FxA-Sync@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 17 KiB |
23
mobile/ios/Client/Frontend/Settings/Settings.xcassets/RedCaution.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "RedCaution.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "RedCaution@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "RedCaution@3x.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
BIN
mobile/ios/Client/Frontend/Settings/Settings.xcassets/RedCaution.imageset/RedCaution.png
vendored
Normal file
|
After Width: | Height: | Size: 482 B |
BIN
mobile/ios/Client/Frontend/Settings/Settings.xcassets/RedCaution.imageset/RedCaution@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 881 B |
BIN
mobile/ios/Client/Frontend/Settings/Settings.xcassets/RedCaution.imageset/RedCaution@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
23
mobile/ios/Client/Frontend/Settings/Settings.xcassets/placeholder-avatar.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "placeholder-avatar.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "placeholder-avatar@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "placeholder-avatar@3x.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
|
@ -0,0 +1,182 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import Foundation
|
||||
import Shared
|
||||
import SnapKit
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
let DefaultTimeoutTimeInterval = 10.0 // Seconds. We'll want some telemetry on load times in the wild.
|
||||
private var TODOPageLoadErrorString = NSLocalizedString("Could not load page.", comment: "Error message that is shown in settings when there was a problem loading")
|
||||
|
||||
/**
|
||||
* A controller that manages a single web view and provides a way for
|
||||
* the user to navigate back to Settings.
|
||||
*/
|
||||
class SettingsContentViewController: UIViewController, WKNavigationDelegate {
|
||||
let interstitialBackgroundColor: UIColor
|
||||
var settingsTitle: NSAttributedString?
|
||||
var url: URL!
|
||||
var timer: Timer?
|
||||
|
||||
var isLoaded: Bool = false {
|
||||
didSet {
|
||||
if isLoaded {
|
||||
UIView.transition(from: interstitialView, to: webView,
|
||||
duration: 0.5,
|
||||
options: UIViewAnimationOptions.transitionCrossDissolve,
|
||||
completion: { finished in
|
||||
self.interstitialView.removeFromSuperview()
|
||||
self.interstitialSpinnerView.stopAnimating()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate var isError: Bool = false {
|
||||
didSet {
|
||||
if isError {
|
||||
interstitialErrorView.isHidden = false
|
||||
UIView.transition(from: interstitialSpinnerView, to: interstitialErrorView,
|
||||
duration: 0.5,
|
||||
options: UIViewAnimationOptions.transitionCrossDissolve,
|
||||
completion: { finished in
|
||||
self.interstitialSpinnerView.removeFromSuperview()
|
||||
self.interstitialSpinnerView.stopAnimating()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The view shown while the content is loading in the background web view.
|
||||
fileprivate var interstitialView: UIView!
|
||||
fileprivate var interstitialSpinnerView: UIActivityIndicatorView!
|
||||
fileprivate var interstitialErrorView: UILabel!
|
||||
|
||||
// The web view that displays content.
|
||||
var webView: WKWebView!
|
||||
|
||||
fileprivate func startLoading(_ timeout: Double = DefaultTimeoutTimeInterval) {
|
||||
if self.isLoaded {
|
||||
return
|
||||
}
|
||||
if timeout > 0 {
|
||||
self.timer = Timer.scheduledTimer(timeInterval: timeout, target: self, selector: #selector(SettingsContentViewController.SELdidTimeOut), userInfo: nil, repeats: false)
|
||||
} else {
|
||||
self.timer = nil
|
||||
}
|
||||
self.webView.load(URLRequest(url: url))
|
||||
self.interstitialSpinnerView.startAnimating()
|
||||
}
|
||||
|
||||
init(backgroundColor: UIColor = UIColor.white, title: NSAttributedString? = nil) {
|
||||
interstitialBackgroundColor = backgroundColor
|
||||
settingsTitle = title
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
// This background agrees with the web page background.
|
||||
// Keeping the background constant prevents a pop of mismatched color.
|
||||
view.backgroundColor = interstitialBackgroundColor
|
||||
|
||||
self.webView = makeWebView()
|
||||
view.addSubview(webView)
|
||||
self.webView.snp.remakeConstraints { make in
|
||||
make.edges.equalTo(self.view)
|
||||
}
|
||||
|
||||
// Destructuring let causes problems.
|
||||
let ret = makeInterstitialViews()
|
||||
self.interstitialView = ret.0
|
||||
self.interstitialSpinnerView = ret.1
|
||||
self.interstitialErrorView = ret.2
|
||||
view.addSubview(interstitialView)
|
||||
self.interstitialView.snp.remakeConstraints { make in
|
||||
make.edges.equalTo(self.view)
|
||||
}
|
||||
|
||||
startLoading()
|
||||
}
|
||||
|
||||
func makeWebView() -> WKWebView {
|
||||
let config = WKWebViewConfiguration()
|
||||
let webView = WKWebView(
|
||||
frame: CGRect(x: 0, y: 0, width: 1, height: 1),
|
||||
configuration: config
|
||||
)
|
||||
webView.allowsLinkPreview = false
|
||||
webView.navigationDelegate = self
|
||||
return webView
|
||||
}
|
||||
|
||||
fileprivate func makeInterstitialViews() -> (UIView, UIActivityIndicatorView, UILabel) {
|
||||
let view = UIView()
|
||||
|
||||
// Keeping the background constant prevents a pop of mismatched color.
|
||||
view.backgroundColor = interstitialBackgroundColor
|
||||
|
||||
let spinner = UIActivityIndicatorView(activityIndicatorStyle: UIActivityIndicatorViewStyle.gray)
|
||||
view.addSubview(spinner)
|
||||
|
||||
let error = UILabel()
|
||||
if let _ = settingsTitle {
|
||||
error.text = TODOPageLoadErrorString
|
||||
error.textColor = UIColor.red // Firefox Orange!
|
||||
error.textAlignment = NSTextAlignment.center
|
||||
}
|
||||
error.isHidden = true
|
||||
view.addSubview(error)
|
||||
|
||||
spinner.snp.makeConstraints { make in
|
||||
make.center.equalTo(view)
|
||||
return
|
||||
}
|
||||
|
||||
error.snp.makeConstraints { make in
|
||||
make.center.equalTo(view)
|
||||
make.left.equalTo(view.snp.left).offset(20)
|
||||
make.right.equalTo(view.snp.right).offset(-20)
|
||||
make.height.equalTo(44)
|
||||
return
|
||||
}
|
||||
|
||||
return (view, spinner, error)
|
||||
}
|
||||
|
||||
func SELdidTimeOut() {
|
||||
self.timer = nil
|
||||
self.isError = true
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
|
||||
// If this is a request to our local web server, use our private credentials.
|
||||
if challenge.protectionSpace.host == "localhost" && challenge.protectionSpace.port == Int(WebServer.sharedInstance.server.port) {
|
||||
completionHandler(.useCredential, WebServer.sharedInstance.credentials)
|
||||
return
|
||||
}
|
||||
completionHandler(URLSession.AuthChallengeDisposition.performDefaultHandling, nil)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
SELdidTimeOut()
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
SELdidTimeOut()
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
self.timer?.invalidate()
|
||||
self.timer = nil
|
||||
self.isLoaded = true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import UIKit
|
||||
|
||||
class SettingsNavigationController: UINavigationController {
|
||||
var popoverDelegate: PresentingModalViewControllerDelegate?
|
||||
|
||||
func SELdone() {
|
||||
if let delegate = popoverDelegate {
|
||||
delegate.dismissPresentedModalViewController(self, animated: true)
|
||||
} else {
|
||||
self.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
return UIStatusBarStyle.default
|
||||
}
|
||||
}
|
||||
|
||||
protocol PresentingModalViewControllerDelegate {
|
||||
func dismissPresentedModalViewController(_ modalViewController: UIViewController, animated: Bool)
|
||||
}
|
||||
|
||||
class ModalSettingsNavigationController: UINavigationController {
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
return UIStatusBarStyle.default
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,738 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import Account
|
||||
import Shared
|
||||
import UIKit
|
||||
|
||||
// A base setting class that shows a title. You probably want to subclass this, not use it directly.
|
||||
class Setting: NSObject {
|
||||
fileprivate var _title: NSAttributedString?
|
||||
fileprivate var _footerTitle: NSAttributedString?
|
||||
fileprivate var _cellHeight: CGFloat?
|
||||
fileprivate var _image: UIImage?
|
||||
|
||||
weak var delegate: SettingsDelegate?
|
||||
|
||||
// The url the SettingsContentViewController will show, e.g. Licenses and Privacy Policy.
|
||||
var url: URL? { return nil }
|
||||
|
||||
// The title shown on the pref.
|
||||
var title: NSAttributedString? { return _title }
|
||||
var footerTitle: NSAttributedString? { return _footerTitle }
|
||||
var cellHeight: CGFloat? { return _cellHeight}
|
||||
fileprivate(set) var accessibilityIdentifier: String?
|
||||
|
||||
// An optional second line of text shown on the pref.
|
||||
var status: NSAttributedString? { return nil }
|
||||
|
||||
// Whether or not to show this pref.
|
||||
var hidden: Bool { return false }
|
||||
|
||||
var style: UITableViewCellStyle { return .subtitle }
|
||||
|
||||
var accessoryType: UITableViewCellAccessoryType { return .none }
|
||||
|
||||
var textAlignment: NSTextAlignment { return .natural }
|
||||
|
||||
var image: UIImage? { return _image }
|
||||
|
||||
fileprivate(set) var enabled: Bool = true
|
||||
|
||||
// Called when the cell is setup. Call if you need the default behaviour.
|
||||
func onConfigureCell(_ cell: UITableViewCell) {
|
||||
cell.detailTextLabel?.attributedText = status
|
||||
cell.detailTextLabel?.numberOfLines = 0
|
||||
cell.textLabel?.attributedText = title
|
||||
cell.textLabel?.textAlignment = textAlignment
|
||||
cell.textLabel?.numberOfLines = 1
|
||||
cell.textLabel?.lineBreakMode = .byTruncatingTail
|
||||
cell.accessoryType = accessoryType
|
||||
cell.accessoryView = nil
|
||||
cell.selectionStyle = enabled ? .default : .none
|
||||
cell.accessibilityIdentifier = accessibilityIdentifier
|
||||
cell.imageView?.image = _image
|
||||
if let title = title?.string {
|
||||
if let detailText = cell.detailTextLabel?.text {
|
||||
cell.accessibilityLabel = "\(title), \(detailText)"
|
||||
} else if let status = status?.string {
|
||||
cell.accessibilityLabel = "\(title), \(status)"
|
||||
} else {
|
||||
cell.accessibilityLabel = title
|
||||
}
|
||||
}
|
||||
cell.accessibilityTraits = UIAccessibilityTraitButton
|
||||
cell.indentationWidth = 0
|
||||
cell.layoutMargins = UIEdgeInsets.zero
|
||||
// So that the separator line goes all the way to the left edge.
|
||||
cell.separatorInset = UIEdgeInsets.zero
|
||||
}
|
||||
|
||||
// Called when the pref is tapped.
|
||||
func onClick(_ navigationController: UINavigationController?) { return }
|
||||
|
||||
// Helper method to set up and push a SettingsContentViewController
|
||||
func setUpAndPushSettingsContentViewController(_ navigationController: UINavigationController?) {
|
||||
if let url = self.url {
|
||||
let viewController = SettingsContentViewController()
|
||||
viewController.settingsTitle = self.title
|
||||
viewController.url = url
|
||||
navigationController?.pushViewController(viewController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
init(title: NSAttributedString? = nil, footerTitle: NSAttributedString? = nil, cellHeight: CGFloat? = nil, delegate: SettingsDelegate? = nil, enabled: Bool? = nil) {
|
||||
self._title = title
|
||||
self._footerTitle = footerTitle
|
||||
self._cellHeight = cellHeight
|
||||
self.delegate = delegate
|
||||
self.enabled = enabled ?? true
|
||||
}
|
||||
}
|
||||
|
||||
// A setting in the sections panel. Contains a sublist of Settings
|
||||
class SettingSection: Setting {
|
||||
fileprivate let children: [Setting]
|
||||
|
||||
init(title: NSAttributedString? = nil, footerTitle: NSAttributedString? = nil, cellHeight: CGFloat? = nil, children: [Setting]) {
|
||||
self.children = children
|
||||
super.init(title: title, footerTitle: footerTitle, cellHeight: cellHeight)
|
||||
}
|
||||
|
||||
var count: Int {
|
||||
var count = 0
|
||||
for setting in children where !setting.hidden {
|
||||
count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
subscript(val: Int) -> Setting? {
|
||||
var i = 0
|
||||
for setting in children where !setting.hidden {
|
||||
if i == val {
|
||||
return setting
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private class PaddedSwitch: UIView {
|
||||
fileprivate static let Padding: CGFloat = 8
|
||||
|
||||
init(switchView: UISwitch) {
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
addSubview(switchView)
|
||||
|
||||
frame.size = CGSize(width: switchView.frame.width + PaddedSwitch.Padding, height: switchView.frame.height)
|
||||
switchView.frame.origin = CGPoint(x: PaddedSwitch.Padding, y: 0)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
// A helper class for settings with a UISwitch.
|
||||
// Takes and optional settingsDidChange callback and status text.
|
||||
class BoolSetting: Setting {
|
||||
let prefKey: String? // Sometimes a subclass will manage its own pref setting. In that case the prefkey will be nil
|
||||
|
||||
fileprivate let prefs: Prefs
|
||||
fileprivate let defaultValue: Bool
|
||||
fileprivate let settingDidChange: ((Bool) -> Void)?
|
||||
fileprivate let statusText: NSAttributedString?
|
||||
|
||||
init(prefs: Prefs, prefKey: String? = nil, defaultValue: Bool, attributedTitleText: NSAttributedString, attributedStatusText: NSAttributedString? = nil, settingDidChange: ((Bool) -> Void)? = nil) {
|
||||
self.prefs = prefs
|
||||
self.prefKey = prefKey
|
||||
self.defaultValue = defaultValue
|
||||
self.settingDidChange = settingDidChange
|
||||
self.statusText = attributedStatusText
|
||||
super.init(title: attributedTitleText)
|
||||
}
|
||||
|
||||
convenience init(prefs: Prefs, prefKey: String? = nil, defaultValue: Bool, titleText: String, statusText: String? = nil, settingDidChange: ((Bool) -> Void)? = nil) {
|
||||
var statusTextAttributedString: NSAttributedString?
|
||||
if let statusTextString = statusText {
|
||||
statusTextAttributedString = NSAttributedString(string: statusTextString, attributes: [NSForegroundColorAttributeName: UIConstants.TableViewHeaderTextColor])
|
||||
}
|
||||
self.init(prefs: prefs, prefKey: prefKey, defaultValue: defaultValue, attributedTitleText: NSAttributedString(string: titleText, attributes: [NSForegroundColorAttributeName: UIConstants.TableViewRowTextColor]), attributedStatusText: statusTextAttributedString, settingDidChange: settingDidChange)
|
||||
}
|
||||
|
||||
override var status: NSAttributedString? {
|
||||
return statusText
|
||||
}
|
||||
|
||||
override func onConfigureCell(_ cell: UITableViewCell) {
|
||||
super.onConfigureCell(cell)
|
||||
|
||||
let control = UISwitch()
|
||||
control.onTintColor = UIConstants.SystemBlueColor
|
||||
control.addTarget(self, action: #selector(BoolSetting.switchValueChanged(_:)), for: UIControlEvents.valueChanged)
|
||||
displayBool(control)
|
||||
if let title = title {
|
||||
if let status = status {
|
||||
control.accessibilityLabel = "\(title.string), \(status.string)"
|
||||
} else {
|
||||
control.accessibilityLabel = title.string
|
||||
}
|
||||
cell.accessibilityLabel = nil
|
||||
}
|
||||
cell.accessoryView = PaddedSwitch(switchView: control)
|
||||
cell.selectionStyle = .none
|
||||
}
|
||||
|
||||
@objc func switchValueChanged(_ control: UISwitch) {
|
||||
writeBool(control)
|
||||
settingDidChange?(control.isOn)
|
||||
UnifiedTelemetry.recordEvent(category: .action, method: .change, object: .setting, value: self.prefKey, extras: ["to": control.isOn])
|
||||
}
|
||||
|
||||
// These methods allow a subclass to control how the pref is saved
|
||||
func displayBool(_ control: UISwitch) {
|
||||
guard let key = prefKey else {
|
||||
return
|
||||
}
|
||||
control.isOn = prefs.boolForKey(key) ?? defaultValue
|
||||
}
|
||||
|
||||
func writeBool(_ control: UISwitch) {
|
||||
guard let key = prefKey else {
|
||||
return
|
||||
}
|
||||
prefs.setBool(control.isOn, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
/// A helper class for a setting backed by a UITextField.
|
||||
/// This takes an optional settingIsValid and settingDidChange callback
|
||||
/// If settingIsValid returns false, the Setting will not change and the text remains red.
|
||||
class StringSetting: Setting, UITextFieldDelegate {
|
||||
|
||||
let prefKey: String
|
||||
fileprivate let Padding: CGFloat = 8
|
||||
|
||||
fileprivate let prefs: Prefs
|
||||
fileprivate let defaultValue: String?
|
||||
fileprivate let placeholder: String
|
||||
fileprivate let settingDidChange: ((String?) -> Void)?
|
||||
fileprivate let settingIsValid: ((String?) -> Bool)?
|
||||
|
||||
let textField = UITextField()
|
||||
|
||||
init(prefs: Prefs, prefKey: String, defaultValue: String? = nil, placeholder: String, accessibilityIdentifier: String, settingIsValid isValueValid: ((String?) -> Bool)? = nil, settingDidChange: ((String?) -> Void)? = nil) {
|
||||
self.prefs = prefs
|
||||
self.prefKey = prefKey
|
||||
self.defaultValue = defaultValue
|
||||
self.settingDidChange = settingDidChange
|
||||
self.settingIsValid = isValueValid
|
||||
self.placeholder = placeholder
|
||||
|
||||
super.init()
|
||||
self.accessibilityIdentifier = accessibilityIdentifier
|
||||
}
|
||||
|
||||
override func onConfigureCell(_ cell: UITableViewCell) {
|
||||
super.onConfigureCell(cell)
|
||||
if let id = accessibilityIdentifier {
|
||||
textField.accessibilityIdentifier = id + "TextField"
|
||||
}
|
||||
textField.placeholder = placeholder
|
||||
textField.textAlignment = .center
|
||||
textField.delegate = self
|
||||
textField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
|
||||
cell.isUserInteractionEnabled = true
|
||||
cell.accessibilityTraits = UIAccessibilityTraitNone
|
||||
cell.contentView.addSubview(textField)
|
||||
|
||||
textField.snp.makeConstraints { make in
|
||||
make.height.equalTo(44)
|
||||
make.trailing.equalTo(cell.contentView).offset(-Padding)
|
||||
make.leading.equalTo(cell.contentView).offset(Padding)
|
||||
}
|
||||
textField.text = prefs.stringForKey(prefKey) ?? defaultValue
|
||||
textFieldDidChange(textField)
|
||||
}
|
||||
|
||||
override func onClick(_ navigationController: UINavigationController?) {
|
||||
textField.becomeFirstResponder()
|
||||
}
|
||||
|
||||
fileprivate func isValid(_ value: String?) -> Bool {
|
||||
guard let test = settingIsValid else {
|
||||
return true
|
||||
}
|
||||
return test(prepareValidValue(userInput: value))
|
||||
}
|
||||
|
||||
/// This gives subclasses an opportunity to treat the user input string
|
||||
/// before it is saved or tested.
|
||||
/// Default implementation does nothing.
|
||||
func prepareValidValue(userInput value: String?) -> String? {
|
||||
return value
|
||||
}
|
||||
|
||||
@objc func textFieldDidChange(_ textField: UITextField) {
|
||||
let color = isValid(textField.text) ? UIConstants.TableViewRowTextColor : UIConstants.DestructiveRed
|
||||
textField.textColor = color
|
||||
}
|
||||
|
||||
@objc func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
return isValid(textField.text)
|
||||
}
|
||||
|
||||
@objc func textFieldDidEndEditing(_ textField: UITextField) {
|
||||
let text = textField.text
|
||||
if !isValid(text) {
|
||||
return
|
||||
}
|
||||
if let text = prepareValidValue(userInput: text) {
|
||||
prefs.setString(text, forKey: prefKey)
|
||||
} else {
|
||||
prefs.removeObjectForKey(prefKey)
|
||||
}
|
||||
// Call settingDidChange with text or nil.
|
||||
settingDidChange?(text)
|
||||
}
|
||||
}
|
||||
|
||||
class CheckmarkSetting: Setting {
|
||||
let onChanged: () -> Void
|
||||
let isEnabled: () -> Bool
|
||||
private let subtitle: NSAttributedString?
|
||||
|
||||
override var status: NSAttributedString? {
|
||||
return subtitle
|
||||
}
|
||||
|
||||
init(title: NSAttributedString, subtitle: NSAttributedString?, accessibilityIdentifier: String? = nil, isEnabled: @escaping () -> Bool, onChanged: @escaping () -> Void) {
|
||||
self.subtitle = subtitle
|
||||
self.onChanged = onChanged
|
||||
self.isEnabled = isEnabled
|
||||
super.init(title: title)
|
||||
self.accessibilityIdentifier = accessibilityIdentifier
|
||||
}
|
||||
|
||||
override func onConfigureCell(_ cell: UITableViewCell) {
|
||||
super.onConfigureCell(cell)
|
||||
cell.accessoryType = isEnabled() ? .checkmark : .none
|
||||
cell.selectionStyle = .none
|
||||
}
|
||||
|
||||
override func onClick(_ navigationController: UINavigationController?) {
|
||||
// Force editing to end for any focused text fields so they can finish up validation first.
|
||||
navigationController?.view.endEditing(true)
|
||||
if !isEnabled() {
|
||||
onChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A helper class for a setting backed by a UITextField.
|
||||
/// This takes an optional isEnabled and mandatory onClick callback
|
||||
/// isEnabled is called on each tableview.reloadData. If it returns
|
||||
/// false then the 'button' appears disabled.
|
||||
class ButtonSetting: Setting {
|
||||
let onButtonClick: (UINavigationController?) -> Void
|
||||
let destructive: Bool
|
||||
let isEnabled: (() -> Bool)?
|
||||
|
||||
init(title: NSAttributedString?, destructive: Bool = false, accessibilityIdentifier: String, isEnabled: (() -> Bool)? = nil, onClick: @escaping (UINavigationController?) -> Void) {
|
||||
self.onButtonClick = onClick
|
||||
self.destructive = destructive
|
||||
self.isEnabled = isEnabled
|
||||
super.init(title: title)
|
||||
self.accessibilityIdentifier = accessibilityIdentifier
|
||||
}
|
||||
|
||||
override func onConfigureCell(_ cell: UITableViewCell) {
|
||||
super.onConfigureCell(cell)
|
||||
|
||||
if isEnabled?() ?? true {
|
||||
cell.textLabel?.textColor = destructive ? UIConstants.DestructiveRed : UIConstants.HighlightBlue
|
||||
} else {
|
||||
cell.textLabel?.textColor = UIConstants.TableViewDisabledRowTextColor
|
||||
}
|
||||
cell.textLabel?.textAlignment = NSTextAlignment.center
|
||||
cell.accessibilityTraits = UIAccessibilityTraitButton
|
||||
cell.selectionStyle = .none
|
||||
}
|
||||
|
||||
override func onClick(_ navigationController: UINavigationController?) {
|
||||
// Force editing to end for any focused text fields so they can finish up validation first.
|
||||
navigationController?.view.endEditing(true)
|
||||
if isEnabled?() ?? true {
|
||||
onButtonClick(navigationController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A helper class for prefs that deal with sync. Handles reloading the tableView data if changes to
|
||||
// the fxAccount happen.
|
||||
class AccountSetting: Setting, FxAContentViewControllerDelegate {
|
||||
unowned var settings: SettingsTableViewController
|
||||
|
||||
var profile: Profile {
|
||||
return settings.profile
|
||||
}
|
||||
|
||||
override var title: NSAttributedString? { return nil }
|
||||
|
||||
init(settings: SettingsTableViewController) {
|
||||
self.settings = settings
|
||||
super.init(title: nil)
|
||||
}
|
||||
|
||||
override func onConfigureCell(_ cell: UITableViewCell) {
|
||||
super.onConfigureCell(cell)
|
||||
if settings.profile.getAccount() != nil {
|
||||
cell.selectionStyle = .none
|
||||
}
|
||||
}
|
||||
|
||||
override var accessoryType: UITableViewCellAccessoryType { return .none }
|
||||
|
||||
func contentViewControllerDidSignIn(_ viewController: FxAContentViewController, withFlags flags: FxALoginFlags) {
|
||||
// This method will get called twice: once when the user signs in, and once
|
||||
// when the account is verified by email – on this device or another.
|
||||
// If the user hasn't dismissed the fxa content view controller,
|
||||
// then we should only do that (thus finishing the sign in/verification process)
|
||||
// once the account is verified.
|
||||
// By the time we get to here, we should be syncing or just about to sync in the
|
||||
// background, most likely from FxALoginHelper.
|
||||
if flags.verified {
|
||||
_ = settings.navigationController?.popToRootViewController(animated: true)
|
||||
// Reload the data to reflect the new Account immediately.
|
||||
settings.tableView.reloadData()
|
||||
// And start advancing the Account state in the background as well.
|
||||
settings.SELrefresh()
|
||||
}
|
||||
}
|
||||
|
||||
func contentViewControllerDidCancel(_ viewController: FxAContentViewController) {
|
||||
NSLog("didCancel")
|
||||
_ = settings.navigationController?.popToRootViewController(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
class WithAccountSetting: AccountSetting {
|
||||
override var hidden: Bool { return !profile.hasAccount() }
|
||||
}
|
||||
|
||||
class WithoutAccountSetting: AccountSetting {
|
||||
override var hidden: Bool { return profile.hasAccount() }
|
||||
}
|
||||
|
||||
@objc
|
||||
protocol SettingsDelegate: class {
|
||||
func settingsOpenURLInNewTab(_ url: URL)
|
||||
}
|
||||
|
||||
// The base settings view controller.
|
||||
class SettingsTableViewController: UITableViewController {
|
||||
|
||||
typealias SettingsGenerator = (SettingsTableViewController, SettingsDelegate?) -> [SettingSection]
|
||||
|
||||
fileprivate let Identifier = "CellIdentifier"
|
||||
fileprivate let SectionHeaderIdentifier = "SectionHeaderIdentifier"
|
||||
var settings = [SettingSection]()
|
||||
|
||||
weak var settingsDelegate: SettingsDelegate?
|
||||
|
||||
var profile: Profile!
|
||||
var tabManager: TabManager!
|
||||
|
||||
var hasSectionSeparatorLine = true
|
||||
|
||||
/// Used to calculate cell heights.
|
||||
fileprivate lazy var dummyToggleCell: UITableViewCell = {
|
||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: "dummyCell")
|
||||
cell.accessoryView = UISwitch()
|
||||
return cell
|
||||
}()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: Identifier)
|
||||
tableView.register(SettingsTableSectionHeaderFooterView.self, forHeaderFooterViewReuseIdentifier: SectionHeaderIdentifier)
|
||||
tableView.separatorColor = UIConstants.TableViewSeparatorColor
|
||||
tableView.backgroundColor = UIConstants.TableViewHeaderBackgroundColor
|
||||
tableView.tableFooterView = UIView(frame: CGRect(x: 0, y: 0, width: view.frame.width, height: 30))
|
||||
tableView.estimatedRowHeight = 44
|
||||
tableView.estimatedSectionHeaderHeight = 44
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
settings = generateSettings()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(SettingsTableViewController.SELsyncDidChangeState), name: NotificationProfileDidStartSyncing, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(SettingsTableViewController.SELsyncDidChangeState), name: NotificationProfileDidFinishSyncing, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(SettingsTableViewController.SELfirefoxAccountDidChange), name: NotificationFirefoxAccountChanged, object: nil)
|
||||
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
SELrefresh()
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
NotificationCenter.default.removeObserver(self, name: NotificationProfileDidStartSyncing, object: nil)
|
||||
NotificationCenter.default.removeObserver(self, name: NotificationProfileDidFinishSyncing, object: nil)
|
||||
NotificationCenter.default.removeObserver(self, name: NotificationFirefoxAccountChanged, object: nil)
|
||||
}
|
||||
|
||||
// Override to provide settings in subclasses
|
||||
func generateSettings() -> [SettingSection] {
|
||||
return []
|
||||
}
|
||||
|
||||
@objc fileprivate func SELsyncDidChangeState() {
|
||||
DispatchQueue.main.async {
|
||||
self.tableView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
@objc fileprivate func SELrefresh() {
|
||||
// Through-out, be aware that modifying the control while a refresh is in progress is /not/ supported and will likely crash the app.
|
||||
if let account = self.profile.getAccount() {
|
||||
account.advance().upon { state in
|
||||
DispatchQueue.main.async { () -> Void in
|
||||
self.tableView.reloadData()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.tableView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func SELfirefoxAccountDidChange() {
|
||||
self.tableView.reloadData()
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let section = settings[indexPath.section]
|
||||
if let setting = section[indexPath.row] {
|
||||
var cell: UITableViewCell!
|
||||
if let _ = setting.status {
|
||||
// Work around http://stackoverflow.com/a/9999821 and http://stackoverflow.com/a/25901083 by using a new cell.
|
||||
// I could not make any setNeedsLayout solution work in the case where we disconnect and then connect a new account.
|
||||
// Be aware that dequeing and then ignoring a cell appears to cause issues; only deque a cell if you're going to return it.
|
||||
cell = UITableViewCell(style: setting.style, reuseIdentifier: nil)
|
||||
} else {
|
||||
cell = tableView.dequeueReusableCell(withIdentifier: Identifier, for: indexPath)
|
||||
}
|
||||
setting.onConfigureCell(cell)
|
||||
return cell
|
||||
}
|
||||
return tableView.dequeueReusableCell(withIdentifier: Identifier, for: indexPath)
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return settings.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
let section = settings[section]
|
||||
return section.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: SectionHeaderIdentifier) as! SettingsTableSectionHeaderFooterView
|
||||
let sectionSetting = settings[section]
|
||||
if let sectionTitle = sectionSetting.title?.string {
|
||||
headerView.titleLabel.text = sectionTitle.uppercased()
|
||||
}
|
||||
// Hide the top border for the top section to avoid having a double line at the top
|
||||
if section == 0 || !hasSectionSeparatorLine {
|
||||
headerView.showTopBorder = false
|
||||
} else {
|
||||
headerView.showTopBorder = true
|
||||
}
|
||||
|
||||
return headerView
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
|
||||
let sectionSetting = settings[section]
|
||||
guard let sectionFooter = sectionSetting.footerTitle?.string else {
|
||||
return nil
|
||||
}
|
||||
let footerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: SectionHeaderIdentifier) as! SettingsTableSectionHeaderFooterView
|
||||
footerView.titleLabel.text = sectionFooter
|
||||
footerView.titleAlignment = .top
|
||||
footerView.showBottomBorder = false
|
||||
return footerView
|
||||
}
|
||||
|
||||
// To hide a footer dynamically requires returning nil from viewForFooterInSection
|
||||
// and setting the height to zero.
|
||||
// However, we also want the height dynamically calculated, there is a magic constant
|
||||
// for that: `UITableViewAutomaticDimension`.
|
||||
override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
|
||||
let sectionSetting = settings[section]
|
||||
if let _ = sectionSetting.footerTitle?.string {
|
||||
return UITableViewAutomaticDimension
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
let section = settings[indexPath.section]
|
||||
// Workaround for calculating the height of default UITableViewCell cells with a subtitle under
|
||||
// the title text label.
|
||||
if let setting = section[indexPath.row], setting is BoolSetting && setting.status != nil {
|
||||
return calculateStatusCellHeightForSetting(setting)
|
||||
}
|
||||
if let setting = section[indexPath.row], let height = setting.cellHeight {
|
||||
return height
|
||||
}
|
||||
|
||||
return UITableViewAutomaticDimension
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
let section = settings[indexPath.section]
|
||||
if let setting = section[indexPath.row], setting.enabled {
|
||||
setting.onClick(navigationController)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func calculateStatusCellHeightForSetting(_ setting: Setting) -> CGFloat {
|
||||
dummyToggleCell.layoutSubviews()
|
||||
|
||||
let topBottomMargin: CGFloat = 10
|
||||
let width = dummyToggleCell.contentView.frame.width - 2 * dummyToggleCell.separatorInset.left
|
||||
|
||||
return
|
||||
heightForLabel(dummyToggleCell.textLabel!, width: width, text: setting.title?.string) +
|
||||
heightForLabel(dummyToggleCell.detailTextLabel!, width: width, text: setting.status?.string) +
|
||||
2 * topBottomMargin
|
||||
}
|
||||
|
||||
fileprivate func heightForLabel(_ label: UILabel, width: CGFloat, text: String?) -> CGFloat {
|
||||
guard let text = text else { return 0 }
|
||||
|
||||
let size = CGSize(width: width, height: CGFloat.greatestFiniteMagnitude)
|
||||
let attrs = [NSFontAttributeName: label.font as Any]
|
||||
let boundingRect = NSString(string: text).boundingRect(with: size,
|
||||
options: NSStringDrawingOptions.usesLineFragmentOrigin, attributes: attrs, context: nil)
|
||||
return boundingRect.height
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsTableSectionHeaderFooterViewUX {
|
||||
static let titleHorizontalPadding: CGFloat = 15
|
||||
static let titleVerticalPadding: CGFloat = 6
|
||||
static let titleVerticalLongPadding: CGFloat = 20
|
||||
}
|
||||
|
||||
class SettingsTableSectionHeaderFooterView: UITableViewHeaderFooterView {
|
||||
|
||||
enum TitleAlignment {
|
||||
case top
|
||||
case bottom
|
||||
}
|
||||
|
||||
var titleAlignment: TitleAlignment = .bottom {
|
||||
didSet {
|
||||
remakeTitleAlignmentConstraints()
|
||||
}
|
||||
}
|
||||
|
||||
var showTopBorder: Bool = true {
|
||||
didSet {
|
||||
topBorder.isHidden = !showTopBorder
|
||||
}
|
||||
}
|
||||
|
||||
var showBottomBorder: Bool = true {
|
||||
didSet {
|
||||
bottomBorder.isHidden = !showBottomBorder
|
||||
}
|
||||
}
|
||||
|
||||
lazy var titleLabel: UILabel = {
|
||||
var headerLabel = UILabel()
|
||||
headerLabel.textColor = UIConstants.TableViewHeaderTextColor
|
||||
headerLabel.font = UIFont.systemFont(ofSize: 12.0, weight: UIFontWeightRegular)
|
||||
headerLabel.numberOfLines = 0
|
||||
return headerLabel
|
||||
}()
|
||||
|
||||
fileprivate lazy var topBorder: UIView = {
|
||||
let topBorder = UIView()
|
||||
topBorder.backgroundColor = UIConstants.SeparatorColor
|
||||
return topBorder
|
||||
}()
|
||||
|
||||
fileprivate lazy var bottomBorder: UIView = {
|
||||
let bottomBorder = UIView()
|
||||
bottomBorder.backgroundColor = UIConstants.SeparatorColor
|
||||
return bottomBorder
|
||||
}()
|
||||
|
||||
override init(reuseIdentifier: String?) {
|
||||
super.init(reuseIdentifier: reuseIdentifier)
|
||||
contentView.backgroundColor = UIConstants.TableViewHeaderBackgroundColor
|
||||
addSubview(titleLabel)
|
||||
addSubview(topBorder)
|
||||
addSubview(bottomBorder)
|
||||
|
||||
setupInitialConstraints()
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func setupInitialConstraints() {
|
||||
bottomBorder.snp.makeConstraints { make in
|
||||
make.bottom.left.right.equalTo(self)
|
||||
make.height.equalTo(0.5)
|
||||
}
|
||||
|
||||
topBorder.snp.makeConstraints { make in
|
||||
make.top.left.right.equalTo(self)
|
||||
make.height.equalTo(0.5)
|
||||
}
|
||||
|
||||
remakeTitleAlignmentConstraints()
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
showTopBorder = true
|
||||
showBottomBorder = true
|
||||
titleLabel.text = nil
|
||||
titleAlignment = .bottom
|
||||
}
|
||||
|
||||
fileprivate func remakeTitleAlignmentConstraints() {
|
||||
switch titleAlignment {
|
||||
case .top:
|
||||
titleLabel.snp.remakeConstraints { make in
|
||||
make.left.right.equalTo(self).inset(SettingsTableSectionHeaderFooterViewUX.titleHorizontalPadding)
|
||||
make.top.equalTo(self).offset(SettingsTableSectionHeaderFooterViewUX.titleVerticalPadding)
|
||||
make.bottom.equalTo(self).offset(-SettingsTableSectionHeaderFooterViewUX.titleVerticalLongPadding)
|
||||
}
|
||||
case .bottom:
|
||||
titleLabel.snp.remakeConstraints { make in
|
||||
make.left.right.equalTo(self).inset(SettingsTableSectionHeaderFooterViewUX.titleHorizontalPadding)
|
||||
make.bottom.equalTo(self).offset(-SettingsTableSectionHeaderFooterViewUX.titleVerticalPadding)
|
||||
make.top.equalTo(self).offset(SettingsTableSectionHeaderFooterViewUX.titleVerticalLongPadding)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||