Dactyloidae iOS initial commit

This commit is contained in:
wuggy 2026-06-26 21:04:09 -07:00
commit 7154a0497e
2123 changed files with 197052 additions and 0 deletions

View file

@ -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
}
}

File diff suppressed because it is too large Load diff

View 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()
}
}
}

View file

@ -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)
}
}

View 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()
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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()
}
}

View file

@ -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]
}
}

View 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)
}
}

View file

@ -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)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View 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"
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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)
}
}
}
}