From a331a77479c94ad0fcd27a2f3c0128c9608afe73 Mon Sep 17 00:00:00 2001 From: Sawyer Blatz Date: Fri, 16 Nov 2018 14:03:40 -0500 Subject: [PATCH] Closes #1151: Add search suggestions (#1581) Closes #1151: Add search suggestions --- Blockzilla.xcodeproj/project.pbxproj | 26 +- Blockzilla/AppDelegate.swift | 2 + Blockzilla/BrowserViewController.swift | 45 +++- Blockzilla/Debouncer.swift | 26 ++ Blockzilla/OverlayView.swift | 243 +++++++++++++------ Blockzilla/SearchEngine.swift | 31 ++- Blockzilla/SearchSuggestClient.swift | 50 ++++ Blockzilla/SearchSuggestionsPromptView.swift | 125 ++++++++++ Blockzilla/SettingsViewController.swift | 240 +++++++++--------- Blockzilla/TelemetryIntegration.swift | 4 + Blockzilla/UIConstants.swift | 16 +- Blockzilla/UIDeviceExtensions.swift | 23 ++ Blockzilla/URLBar.swift | 6 + ClientTests/SearchEngineTests.swift | 75 ++++++ Shared/Settings.swift | 2 + XCUITest/SearchProviderTest.swift | 4 + XCUITest/SearchSuggestionsTest.swift | 161 ++++++++++++ 17 files changed, 877 insertions(+), 202 deletions(-) create mode 100644 Blockzilla/Debouncer.swift create mode 100644 Blockzilla/SearchSuggestClient.swift create mode 100644 Blockzilla/SearchSuggestionsPromptView.swift create mode 100644 Blockzilla/UIDeviceExtensions.swift create mode 100644 ClientTests/SearchEngineTests.swift create mode 100644 XCUITest/SearchSuggestionsTest.swift diff --git a/Blockzilla.xcodeproj/project.pbxproj b/Blockzilla.xcodeproj/project.pbxproj index d308702856..be5a8e33cd 100644 --- a/Blockzilla.xcodeproj/project.pbxproj +++ b/Blockzilla.xcodeproj/project.pbxproj @@ -44,10 +44,12 @@ 1DD317E120BF1B3000DFA44E /* OnboardingTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DD317E020BF1B3000DFA44E /* OnboardingTest.swift */; }; 1DE6DA2220BF410400CE337B /* QuickAddAutocompleteURLTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DE6DA2120BF410400CE337B /* QuickAddAutocompleteURLTest.swift */; }; 1DE903CE20C751D7002E53ED /* FindInPageTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DE903CD20C751D7002E53ED /* FindInPageTest.swift */; }; + 24433101219DA46F00778D02 /* Debouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24433100219DA46F00778D02 /* Debouncer.swift */; }; 2696EB04211F540600F0C73F /* SearchHistoryUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2696EB03211F540600F0C73F /* SearchHistoryUtils.swift */; }; 2825C3665AB14081B262F767 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1D051AC1857A0EEEBB833D15 /* SystemConfiguration.framework */; }; 2B36326E97D2E67E9C684B4B /* CoreTelephony.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 80D9AA926EFBD788739486EB /* CoreTelephony.framework */; }; 2F2F84BCF076B21DE42D1F29 /* CoreMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C652A61E213D254A86DF5736 /* CoreMedia.framework */; }; + 30DFF98021810BF20055707C /* SearchSuggestClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30DFF97F21810BF20055707C /* SearchSuggestClient.swift */; }; 31421EB12176492A0015F48B /* TitleActivityItemProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31421EB02176492A0015F48B /* TitleActivityItemProvider.swift */; }; 34056D10515852F370C83A01 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AAB8E622E994545108473554 /* QuartzCore.framework */; }; 4285B8E0671F40DA543A2E58 /* AssetsLibrary.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 29B90C056305836CB297FF79 /* AssetsLibrary.framework */; }; @@ -79,6 +81,7 @@ B3D23BEF1FA3A9E500D9C50F /* postload.js in Resources */ = {isa = PBXBuildFile; fileRef = B3D23BEE1FA3A9E500D9C50F /* postload.js */; }; B3F4A3171FA136E70029A6F2 /* preload.js in Resources */ = {isa = PBXBuildFile; fileRef = B3F4A3161FA136E60029A6F2 /* preload.js */; }; D00A44111EB8F80A00DB0218 /* Telemetry.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D00A44101EB8F80A00DB0218 /* Telemetry.framework */; }; + D025F225218B64D600B262D8 /* SearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D025F224218B64D600B262D8 /* SearchEngineTests.swift */; }; D0967A811EC50002009D937F /* TelemetryIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0967A801EC50002009D937F /* TelemetryIntegration.swift */; }; D30179071BC6CB19009AD388 /* BlockerEnabledDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30179061BC6CB19009AD388 /* BlockerEnabledDetector.swift */; }; D30179C31BCC6F65009AD388 /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30179C21BCC6F65009AD388 /* AboutViewController.swift */; }; @@ -181,6 +184,9 @@ EB84F96F209380CE00BA6739 /* URLExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB84F96E209380CE00BA6739 /* URLExtensions.swift */; }; EB84F9712093815500BA6739 /* effective_tld_names.dat in Resources */ = {isa = PBXBuildFile; fileRef = EB84F9702093815500BA6739 /* effective_tld_names.dat */; }; EBE44F4E20ADDF6A005AFEA6 /* SmartLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBE44F4D20ADDF6A005AFEA6 /* SmartLabel.swift */; }; + F74E8109218183F400D18535 /* SearchSuggestionsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = F74E8108218183F400D18535 /* SearchSuggestionsTest.swift */; }; + F7B3E49D2165C32B00118785 /* SearchSuggestionsPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7B3E49C2165C32B00118785 /* SearchSuggestionsPromptView.swift */; }; + F7FF8B5B2194084000CCA80F /* UIDeviceExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FF8B5A2194084000CCA80F /* UIDeviceExtensions.swift */; }; F805722F1DBEE504004339C1 /* WebCacheUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = F805722E1DBEE504004339C1 /* WebCacheUtils.swift */; }; F84AFE751DE77FE6005C4DD1 /* LaunchScreen.png in Resources */ = {isa = PBXBuildFile; fileRef = F84AFE721DE77FE6005C4DD1 /* LaunchScreen.png */; }; F84AFE761DE77FE6005C4DD1 /* LaunchScreen@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F84AFE731DE77FE6005C4DD1 /* LaunchScreen@2x.png */; }; @@ -371,8 +377,10 @@ 1DD317E020BF1B3000DFA44E /* OnboardingTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTest.swift; sourceTree = ""; }; 1DE6DA2120BF410400CE337B /* QuickAddAutocompleteURLTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickAddAutocompleteURLTest.swift; sourceTree = ""; }; 1DE903CD20C751D7002E53ED /* FindInPageTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindInPageTest.swift; sourceTree = ""; }; + 24433100219DA46F00778D02 /* Debouncer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debouncer.swift; sourceTree = ""; }; 2696EB03211F540600F0C73F /* SearchHistoryUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryUtils.swift; sourceTree = ""; }; 29B90C056305836CB297FF79 /* AssetsLibrary.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AssetsLibrary.framework; path = System/Library/Frameworks/AssetsLibrary.framework; sourceTree = SDKROOT; }; + 30DFF97F21810BF20055707C /* SearchSuggestClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSuggestClient.swift; sourceTree = ""; }; 31421EB02176492A0015F48B /* TitleActivityItemProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TitleActivityItemProvider.swift; sourceTree = ""; }; 427B752EF11959F9C38B12D6 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = System/Library/Frameworks/AVFoundation.framework; sourceTree = SDKROOT; }; 4F1284851FC5E242001A775B /* TPSettingsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TPSettingsTest.swift; sourceTree = ""; }; @@ -566,6 +574,7 @@ D00A440E1EB8F40A00DB0218 /* Cartfile */ = {isa = PBXFileReference; lastKnownFileType = text; path = Cartfile; sourceTree = ""; }; D00A440F1EB8F40A00DB0218 /* Cartfile.resolved */ = {isa = PBXFileReference; lastKnownFileType = text; path = Cartfile.resolved; sourceTree = ""; }; D00A44101EB8F80A00DB0218 /* Telemetry.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Telemetry.framework; path = Carthage/Build/iOS/Telemetry.framework; sourceTree = ""; }; + D025F224218B64D600B262D8 /* SearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchEngineTests.swift; sourceTree = ""; }; D0967A801EC50002009D937F /* TelemetryIntegration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TelemetryIntegration.swift; sourceTree = ""; }; D30179061BC6CB19009AD388 /* BlockerEnabledDetector.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlockerEnabledDetector.swift; sourceTree = ""; }; D30179C21BCC6F65009AD388 /* AboutViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AboutViewController.swift; sourceTree = ""; }; @@ -909,6 +918,9 @@ EB84F9702093815500BA6739 /* effective_tld_names.dat */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = effective_tld_names.dat; sourceTree = ""; }; EBE44F4D20ADDF6A005AFEA6 /* SmartLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SmartLabel.swift; sourceTree = ""; }; F4C4943B406FCA9B74B4E186 /* CoreText.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreText.framework; path = System/Library/Frameworks/CoreText.framework; sourceTree = SDKROOT; }; + F74E8108218183F400D18535 /* SearchSuggestionsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSuggestionsTest.swift; sourceTree = ""; }; + F7B3E49C2165C32B00118785 /* SearchSuggestionsPromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSuggestionsPromptView.swift; sourceTree = ""; }; + F7FF8B5A2194084000CCA80F /* UIDeviceExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIDeviceExtensions.swift; sourceTree = ""; }; F805722E1DBEE504004339C1 /* WebCacheUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebCacheUtils.swift; sourceTree = ""; }; F84AFE721DE77FE6005C4DD1 /* LaunchScreen.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = LaunchScreen.png; sourceTree = ""; }; F84AFE731DE77FE6005C4DD1 /* LaunchScreen@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "LaunchScreen@2x.png"; sourceTree = ""; }; @@ -1012,6 +1024,7 @@ 4FE4E6F51FBB5E2C001BB779 /* TPSidebarBadge.swift */, 4F1284851FC5E242001A775B /* TPSettingsTest.swift */, 166E4BFA212F7DEC0029E2A5 /* UserAgentTest.swift */, + F74E8108218183F400D18535 /* SearchSuggestionsTest.swift */, ); path = XCUITest; sourceTree = ""; @@ -1037,7 +1050,6 @@ 745DC5DB1F39221100661635 /* OpenInFocus */ = { isa = PBXGroup; children = ( - 16938C0321010C2D00DCD489 /* InfoPlist.strings */, 745DC5DC1F39221100661635 /* ActionViewController.swift */, 745DC5E11F39221100661635 /* Info.plist */, 742C99D31F3A3AD200717D69 /* Assets.xcassets */, @@ -1194,6 +1206,7 @@ D8E0155D1FCF409F00CA3B9F /* ClientTests */ = { isa = PBXGroup; children = ( + D025F224218B64D600B262D8 /* SearchEngineTests.swift */, D8E0155E1FCF409F00CA3B9F /* SearchEngineManagerTests.swift */, D8E015601FCF409F00CA3B9F /* Info.plist */, D831FEE1205247A400EAE19A /* BrowserViewControllerTests.swift */, @@ -1258,6 +1271,7 @@ E4BF2DD51BACE8CA00DA9D68 /* Blockzilla */ = { isa = PBXGroup; children = ( + 30DFF97F21810BF20055707C /* SearchSuggestClient.swift */, EB84F9702093815500BA6739 /* effective_tld_names.dat */, EB84F96C2093799800BA6739 /* TrackingProtectionPageStats.swift */, D50939A81FBF807A005D4316 /* Settings.bundle */, @@ -1304,6 +1318,7 @@ D3E54FCE1DEFAE80003E1AFF /* OpenSearchParser.swift */, D36C1BA91DB02EBB0073C1AB /* OpenUtils.swift */, D3426AEA1DB7F8AA0016DA5A /* OverlayView.swift */, + F7B3E49C2165C32B00118785 /* SearchSuggestionsPromptView.swift */, 16D716A4211503CD000C8A66 /* PageActionSheetItems.swift */, 16074B6C2114364C003B671F /* PhotonActionSheet.swift */, D3E251E81DAD714C005918DC /* SafariInstructionsViewController.swift */, @@ -1343,6 +1358,8 @@ 746BD9271F75C45A00BE8DB9 /* DictionaryExtensions.swift */, D8E0152B1FB4136E00CA3B9F /* AddSearchEngineViewController.swift */, 2696EB03211F540600F0C73F /* SearchHistoryUtils.swift */, + F7FF8B5A2194084000CCA80F /* UIDeviceExtensions.swift */, + 24433100219DA46F00778D02 /* Debouncer.swift */, ); path = Blockzilla; sourceTree = ""; @@ -1678,7 +1695,6 @@ buildActionMask = 2147483647; files = ( 742C99D41F3A3AD200717D69 /* Assets.xcassets in Resources */, - 16938C0121010C2D00DCD489 /* InfoPlist.strings in Resources */, A89766DA1F57DCA9008183C5 /* (null) in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1824,6 +1840,7 @@ 4F1284861FC5E242001A775B /* TPSettingsTest.swift in Sources */, 4FE4E6F61FBB5E2C001BB779 /* TPSidebarBadge.swift in Sources */, 1DE6DA2220BF410400CE337B /* QuickAddAutocompleteURLTest.swift in Sources */, + F74E8109218183F400D18535 /* SearchSuggestionsTest.swift in Sources */, 0BA39A861DD2B8E4005F970A /* WebsiteAccessTest.swift in Sources */, 0B0D6BC41F3CDDBB00497D08 /* CollapsedURLTest.swift in Sources */, ); @@ -1858,6 +1875,7 @@ D8E0155F1FCF409F00CA3B9F /* SearchEngineManagerTests.swift in Sources */, 050E8B1E2064FECE00DF6090 /* StringExtensionTest.swift in Sources */, D8E0156E1FD9E40F00CA3B9F /* DomainCompletionTests.swift in Sources */, + D025F225218B64D600B262D8 /* SearchEngineTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1905,9 +1923,12 @@ E40AFB101DC9014700DA5651 /* UserAgent.swift in Sources */, EB84F96D2093799900BA6739 /* TrackingProtectionPageStats.swift in Sources */, 74584E351FA1077000AF2582 /* SettingsContentViewController.swift in Sources */, + 24433101219DA46F00778D02 /* Debouncer.swift in Sources */, 7460BD671F58B1B10096B745 /* GradientProgressBar.swift in Sources */, D3E54FE01DF0E221003E1AFF /* SearchSettingsViewController.swift in Sources */, + 30DFF98021810BF20055707C /* SearchSuggestClient.swift in Sources */, 16D7169C2114EF76000C8A66 /* BlockerToggle.swift in Sources */, + F7B3E49D2165C32B00118785 /* SearchSuggestionsPromptView.swift in Sources */, D33E9B241E1D93DD00A39A44 /* RequestHandler.swift in Sources */, D3426AF61DB84E7A0016DA5A /* SearchEngine.swift in Sources */, 746BD9261F75C43C00BE8DB9 /* DataExtensions.swift in Sources */, @@ -1919,6 +1940,7 @@ D37DE55D1BCDBD6100906364 /* WaveView.swift in Sources */, E4BF2DD71BACE8CA00DA9D68 /* AppDelegate.swift in Sources */, D3E2C9691DA3024800DEBE3D /* InsetButton.swift in Sources */, + F7FF8B5B2194084000CCA80F /* UIDeviceExtensions.swift in Sources */, F805722F1DBEE504004339C1 /* WebCacheUtils.swift in Sources */, 1D3BEDFB20C5E76B0019B722 /* FindInPageBar.swift in Sources */, D30DF93E1DC1634F0064736C /* Toast.swift in Sources */, diff --git a/Blockzilla/AppDelegate.swift b/Blockzilla/AppDelegate.swift index b2e6f7e85e..a1a849adf6 100644 --- a/Blockzilla/AppDelegate.swift +++ b/Blockzilla/AppDelegate.swift @@ -45,6 +45,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, ModalDelegate, AppSplashC if let bundleID = Bundle.main.bundleIdentifier { UserDefaults.standard.removePersistentDomain(forName: bundleID) } + UserDefaults.standard.removePersistentDomain(forName: AppInfo.sharedContainerIdentifier) } setupContinuousDeploymentTooling() setupErrorTracking() @@ -364,6 +365,7 @@ extension AppDelegate { telemetryConfig.measureUserDefaultsSetting(forKey: SettingsToggle.blockOther, withDefaultValue: Settings.getToggle(.blockOther)) telemetryConfig.measureUserDefaultsSetting(forKey: SettingsToggle.blockFonts, withDefaultValue: Settings.getToggle(.blockFonts)) telemetryConfig.measureUserDefaultsSetting(forKey: SettingsToggle.biometricLogin, withDefaultValue: Settings.getToggle(.biometricLogin)) + telemetryConfig.measureUserDefaultsSetting(forKey: SettingsToggle.enableSearchSuggestions, withDefaultValue: Settings.getToggle(.enableSearchSuggestions)) #if DEBUG telemetryConfig.updateChannel = "debug" diff --git a/Blockzilla/BrowserViewController.swift b/Blockzilla/BrowserViewController.swift index 68d61837ad..b9196fc6af 100644 --- a/Blockzilla/BrowserViewController.swift +++ b/Blockzilla/BrowserViewController.swift @@ -31,6 +31,7 @@ class BrowserViewController: UIViewController { fileprivate var urlBar: URLBar! fileprivate var topURLBarConstraints = [Constraint]() fileprivate let requestHandler = RequestHandler() + fileprivate let searchSuggestClient = SearchSuggestClient() fileprivate var findInPageBar: FindInPageBar? fileprivate var fillerView: UIView? fileprivate let alertStackView = UIStackView() // All content that appears above the footer should be added to this view. (Find In Page/SnackBars) @@ -69,6 +70,7 @@ class BrowserViewController: UIViewController { } } + private let searchSuggestionsDebouncer = Debouncer(timeInterval: 0.1) private var shouldEnsureBrowsingMode = false private var initialUrl: URL? var tipManager: TipManager? @@ -128,6 +130,7 @@ class BrowserViewController: UIViewController { overlayView.alpha = 0 overlayView.delegate = self overlayView.backgroundColor = UIConstants.colors.overlayBackground + overlayView.setSearchSuggestionsPromptViewDelegate(delegate: self) mainContainerView.addSubview(overlayView) background.snp.makeConstraints { make in @@ -781,9 +784,29 @@ extension BrowserViewController: URLBarDelegate { } func urlBar(_ urlBar: URLBar, didEnterText text: String) { - // Hide find in page if the home view is displayed - let isOnHomeView = homeView != nil - overlayView.setSearchQuery(query: text, animated: true, hideFindInPage: isOnHomeView) + let trimmedText = text.trimmingCharacters(in: .whitespaces) + let isOnHomeView = homeView != nil + + if Settings.getToggle(.enableSearchSuggestions) && !trimmedText.isEmpty { + searchSuggestionsDebouncer.renewInterval() + searchSuggestionsDebouncer.completion = { + self.searchSuggestClient.getSuggestions(trimmedText, callback: { suggestions, error in + let userInputText = urlBar.userInputText?.trimmingCharacters(in: .whitespaces) ?? "" + + // Check if this callback is stale (new user input has been requested) + if userInputText.isEmpty || userInputText != trimmedText { + return + } + + if userInputText == trimmedText { + let suggestions = suggestions ?? [trimmedText] + self.overlayView.setSearchQuery(suggestions: suggestions, hideFindInPage: isOnHomeView || text.isEmpty) + } + }) + } + } else { + overlayView.setSearchQuery(suggestions: [trimmedText], hideFindInPage: isOnHomeView || text.isEmpty) + } } func urlBarDidPressScrollTop(_: URLBar, tap: UITapGestureRecognizer) { @@ -1085,11 +1108,10 @@ extension BrowserViewController: OverlayViewDelegate { } func overlayView(_ overlayView: OverlayView, didSearchForQuery query: String) { - if let url = searchEngineManager.activeEngine.urlForQuery(query) { + if searchEngineManager.activeEngine.urlForQuery(query) != nil { Telemetry.default.recordEvent(category: TelemetryEventCategory.action, method: TelemetryEventMethod.selectQuery, object: TelemetryEventObject.searchBar) Telemetry.default.recordSearch(location: .actionBar, searchEngine: searchEngineManager.activeEngine.getNameOrCustom()) - submit(url: url) - urlBar.url = url + urlBar(urlBar, didSubmitText: query) } urlBar.dismiss() @@ -1139,6 +1161,17 @@ extension BrowserViewController: OverlayViewDelegate { } } +extension BrowserViewController: SearchSuggestionsPromptViewDelegate { + func searchSuggestionsPromptView(_ searchSuggestionsPromptView: SearchSuggestionsPromptView, didEnable: Bool) { + UserDefaults.standard.set(true, forKey: SearchSuggestionsPromptView.respondedToSearchSuggestionsPrompt) + Settings.set(didEnable, forToggle: SettingsToggle.enableSearchSuggestions) + overlayView.updateSearchSuggestionsPrompt(hidden: true) + if didEnable, let urlbar = self.urlBar, let value = self.urlBar?.userInputText { + urlBar(urlbar, didEnterText: value) + } + } +} + extension BrowserViewController: WebControllerDelegate { func webControllerDidStartProvisionalNavigation(_ controller: WebController) { diff --git a/Blockzilla/Debouncer.swift b/Blockzilla/Debouncer.swift new file mode 100644 index 0000000000..ca83216ff4 --- /dev/null +++ b/Blockzilla/Debouncer.swift @@ -0,0 +1,26 @@ +/* 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 + +class Debouncer { + var completion: (() -> Void)? + private let timeInterval: TimeInterval + private var timer: Timer? + + init(timeInterval: TimeInterval, completion: (() -> Void)? = nil) { + self.timeInterval = timeInterval + self.completion = completion + } + + func renewInterval() { + timer?.invalidate() + timer = Timer.scheduledTimer(timeInterval: timeInterval, target: self, selector: #selector(triggerCompletion), userInfo: nil, repeats: false) + } + + @objc private func triggerCompletion() { + guard let timer = timer, timer.isValid else { return } + completion?() + } +} diff --git a/Blockzilla/OverlayView.swift b/Blockzilla/OverlayView.swift index 7d9a779862..49d790519e 100644 --- a/Blockzilla/OverlayView.swift +++ b/Blockzilla/OverlayView.swift @@ -4,6 +4,7 @@ import Foundation import SnapKit +import Telemetry protocol OverlayViewDelegate: class { func overlayViewDidTouchEmptyArea(_ overlayView: OverlayView) @@ -14,46 +15,67 @@ protocol OverlayViewDelegate: class { func overlayView(_ overlayView: OverlayView, didAddToAutocomplete query: String) } +class IndexedInsetButton: InsetButton { + private var index: Int = 0 + func setIndex(_ i:Int) { + index = i + } + func getIndex() -> Int { + return index + } +} + class OverlayView: UIView { weak var delegate: OverlayViewDelegate? private let searchButton = InsetButton() private let addToAutocompleteButton = InsetButton() private var presented = false private var searchQuery = "" + private var searchSuggestions = [String]() + private var searchButtonGroup = [IndexedInsetButton]() private let copyButton = UIButton() private let findInPageButton = InsetButton() + private let searchSuggestionsPrompt = SearchSuggestionsPromptView() private let topBorder = UIView() + private let maxNumOfSuggestions = UIDevice.current.isSmallDevice() ? UIConstants.layout.smallDeviceMaxNumSuggestions : UIConstants.layout.largeDeviceMaxNumSuggestions public var currentURL = "" init() { super.init(frame: CGRect.zero) KeyboardHelper.defaultHelper.addDelegate(delegate: self) - searchButton.isHidden = true - searchButton.accessibilityIdentifier = "OverlayView.searchButton" - searchButton.alpha = 0 - searchButton.setImage(#imageLiteral(resourceName: "icon_searchfor"), for: .normal) - searchButton.setImage(#imageLiteral(resourceName: "icon_searchfor"), for: .highlighted) - searchButton.backgroundColor = UIConstants.colors.background - searchButton.titleLabel?.font = UIConstants.fonts.searchButton - searchButton.backgroundColor = UIConstants.colors.background - setUpOverlayButton(button: searchButton) - searchButton.addTarget(self, action: #selector(didPressSearch), for: .touchUpInside) - addSubview(searchButton) + searchSuggestionsPrompt.backgroundColor = UIConstants.colors.background + searchSuggestionsPrompt.clipsToBounds = true + searchSuggestionsPrompt.accessibilityIdentifier = "SearchSuggestionsPromptView" + addSubview(searchSuggestionsPrompt) + + searchSuggestionsPrompt.snp.makeConstraints { make in + make.top.leading.trailing.equalTo(safeAreaLayoutGuide) + } + + for i in 0..= 0 && !searchButtonGroup[lastSearchButtonIndex].isHidden { + make.top.equalTo(searchButtonGroup[lastSearchButtonIndex].snp.bottom) + } else { + make.top.equalTo(topBorder.snp.bottom) } + make.height.equalTo(UIConstants.layout.overlayButtonHeight) } + + self.setAttributedButtonTitle(phrase: self.searchQuery, button: self.findInPageButton, localizedStringFormat: UIConstants.strings.findInPageButton) } - fileprivate func updateCopyConstraint(showCopyButton: Bool) { - if showCopyButton { - copyButton.isHidden = false - if searchButton.isHidden || searchQuery.isEmpty { - let topConstraint: ConstraintRelatableTarget = addToAutocompleteButton.isHidden ? safeAreaLayoutGuide : addToAutocompleteButton.snp.bottom - copyButton.snp.remakeConstraints { make in - make.top.equalTo(topConstraint) - make.leading.trailing.equalTo(safeAreaLayoutGuide) - make.height.equalTo(56) - } - } else if findInPageButton.isHidden { - copyButton.snp.remakeConstraints { make in - make.leading.trailing.equalTo(safeAreaLayoutGuide) - make.top.equalTo(searchButton.snp.bottom) - make.height.equalTo(56) - } + fileprivate func updateCopyConstraints(copyButtonHidden: Bool, findInPageHidden: Bool, lastSearchButtonIndex: Int) { + copyButton.isHidden = copyButtonHidden + + if copyButtonHidden { + return + } + + copyButton.snp.remakeConstraints { make in + if !findInPageHidden { + make.top.equalTo(findInPageButton.snp.bottom) + } else if lastSearchButtonIndex >= 0 && !searchButtonGroup[lastSearchButtonIndex].isHidden { + make.top.equalTo(searchButtonGroup[lastSearchButtonIndex].snp.bottom) } else { - copyButton.snp.remakeConstraints { make in - make.leading.trailing.equalTo(safeAreaLayoutGuide) - make.top.equalTo(findInPageButton.snp.bottom) - make.height.equalTo(56) - } + make.top.equalTo(topBorder.snp.bottom) } - } else { - copyButton.isHidden = true + + make.leading.trailing.equalTo(safeAreaLayoutGuide) + make.height.equalTo(UIConstants.layout.overlayButtonHeight) } - layoutIfNeeded() } - @objc private func didPressSearch() { - delegate?.overlayView(self, didSearchForQuery: searchQuery) + @objc private func didPressSearch(sender: IndexedInsetButton) { + delegate?.overlayView(self, didSearchForQuery: searchSuggestions[sender.getIndex()]) + + if !Settings.getToggle(.enableSearchSuggestions) { return } + if sender.getIndex() == 0 { + Telemetry.default.recordEvent(category: TelemetryEventCategory.action, method: TelemetryEventMethod.click, object: TelemetryEventObject.searchSuggestionNotSelected) + } else { + Telemetry.default.recordEvent(category: TelemetryEventCategory.action, method: TelemetryEventMethod.click, object: TelemetryEventObject.searchSuggestionSelected) + } } @objc private func didPressCopy() { delegate?.overlayView(self, didSubmitText: UIPasteboard.general.string!) @@ -252,23 +322,38 @@ class OverlayView: UIView { } func dismiss() { - setSearchQuery(query: "", animated: false, hideFindInPage: true) + setSearchQuery(suggestions: [""], hideFindInPage: true) self.isUserInteractionEnabled = false - copyButton.isHidden = true animateHidden(true, duration: UIConstants.layout.overlayAnimationDuration) { self.isUserInteractionEnabled = true } } func present() { - setSearchQuery(query: "", animated: false, hideFindInPage: true) + setSearchQuery(suggestions: [""], hideFindInPage: true) self.isUserInteractionEnabled = false copyButton.isHidden = false - addToAutocompleteButton.animateHidden(currentURL.isEmpty, duration: UIConstants.layout.searchButtonAnimationDuration) + addToAutocompleteButton.animateHidden(currentURL.isEmpty, duration: 0) animateHidden(false, duration: UIConstants.layout.overlayAnimationDuration) { self.isUserInteractionEnabled = true } } + + func setSearchSuggestionsPromptViewDelegate(delegate: SearchSuggestionsPromptViewDelegate) { + searchSuggestionsPrompt.delegate = delegate + } + + func updateSearchSuggestionsPrompt(hidden: Bool) { + searchSuggestionsPrompt.isHidden = hidden + + searchSuggestionsPrompt.snp.remakeConstraints { make in + make.top.leading.trailing.equalTo(safeAreaLayoutGuide) + + if hidden { + make.height.equalTo(0) + } + } + } } extension URL { public func isWebPage() -> Bool { diff --git a/Blockzilla/SearchEngine.swift b/Blockzilla/SearchEngine.swift index ada738fed3..22734e2c28 100644 --- a/Blockzilla/SearchEngine.swift +++ b/Blockzilla/SearchEngine.swift @@ -11,6 +11,8 @@ class SearchEngine: NSObject, NSCoding { private let searchTemplate: String private let suggestionsTemplate: String? + private let SearchTermComponent = "{searchTerms}" + private let LocaleTermComponent = "{moz:locale}" init(name: String, image: UIImage?, searchTemplate: String, suggestionsTemplate: String?, isCustom:Bool = false) { self.name = name @@ -32,15 +34,38 @@ class SearchEngine: NSObject, NSCoding { suggestionsTemplate = aDecoder.decodeObject(forKey: "suggestionsTemplate") as? String } + func urlForSuggestions(_ query: String) -> URL? { + // Escape the search template as well in case it contains not-safe characters like symbols + let templateAllowedSet = NSMutableCharacterSet() + templateAllowedSet.formUnion(with: .urlAllowed) + // Allow brackets since we use them in our template as our insertion point + templateAllowedSet.formUnion(with: CharacterSet(charactersIn: "{}")) + + let trimmed = query.trimmingCharacters(in: .whitespaces) + guard let suggestTemplate = suggestionsTemplate, + let escaped = trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryParameterAllowed), + let encodedSearchTemplate = suggestTemplate.addingPercentEncoding(withAllowedCharacters: templateAllowedSet as CharacterSet) else { + assertionFailure("Invalid search URL") + return nil + } + + let localeString = Locale.current.identifier + let urlString = encodedSearchTemplate + .replacingOccurrences(of: SearchTermComponent, with: escaped, options: .literal, range: nil) + .replacingOccurrences(of: LocaleTermComponent, with: localeString, options: .literal, range: nil) + return URL(string: urlString) + } + func urlForQuery(_ query: String) -> URL? { - guard let escaped = query.addingPercentEncoding(withAllowedCharacters: .urlQueryParameterAllowed) else { + let trimmed = query.trimmingCharacters(in: .whitespaces) + guard let escaped = trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryParameterAllowed) else { assertionFailure("Invalid search URL") return nil } let localeString = NSLocale.current.identifier - guard let urlString = searchTemplate.replacingOccurrences(of: "{searchTerms}", with: escaped) - .replacingOccurrences(of: "{moz:locale}", with: localeString) + guard let urlString = searchTemplate.replacingOccurrences(of: SearchTermComponent, with: escaped) + .replacingOccurrences(of: LocaleTermComponent, with: localeString) .addingPercentEncoding(withAllowedCharacters: .urlAllowed) else { assertionFailure("Invalid search URL") diff --git a/Blockzilla/SearchSuggestClient.swift b/Blockzilla/SearchSuggestClient.swift new file mode 100644 index 0000000000..d32435b09c --- /dev/null +++ b/Blockzilla/SearchSuggestClient.swift @@ -0,0 +1,50 @@ +/* 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 + +let SearchSuggestClientErrorDomain = "org.mozilla.firefox.SearchSuggestClient" +let SearchSuggestClientErrorInvalidEngine = 0 +let SearchSuggestClientErrorInvalidResponse = 1 + +class SearchSuggestClient { + private var request: NSMutableURLRequest? + + func getSuggestions(_ query: String, callback: @escaping (_ response: [String]?, _ error: NSError?) -> Void) { + guard let url = SearchEngineManager(prefs: UserDefaults.standard).activeEngine.urlForSuggestions(query) else { + let error = NSError(domain: SearchSuggestClientErrorDomain, code: SearchSuggestClientErrorInvalidEngine, userInfo: nil) + callback(nil, error) + return + } + + let request = URLRequest(url:url) + URLSession.shared.dataTask(with: request) { (data, response, error) in + do { + // The response will be of the following format: + // ["foobar",["foobar","foobar2000 mac","foobar skins",...]] + // That is, an array of at least two elements: the search term and an array of suggestions. + guard let myData = data, let array = try JSONSerialization.jsonObject(with: myData, options: []) as? [Any] else { + throw NSError(domain: SearchSuggestClientErrorDomain, code: SearchSuggestClientErrorInvalidResponse, userInfo: nil) + } + + if array.count < 2 { + throw NSError(domain: SearchSuggestClientErrorDomain, code: SearchSuggestClientErrorInvalidResponse, userInfo: nil) + } + + if var suggestions = array[1] as? [String] { + if let searchWord = array[0] as? String { + suggestions = suggestions.filter { $0 != searchWord } + suggestions.insert(searchWord, at: 0) + } + callback(suggestions, nil) + return + } + throw NSError(domain: SearchSuggestClientErrorDomain, code: SearchSuggestClientErrorInvalidResponse, userInfo: nil) + } catch let error as NSError { + callback(nil, error) + return + } + }.resume() + } +} diff --git a/Blockzilla/SearchSuggestionsPromptView.swift b/Blockzilla/SearchSuggestionsPromptView.swift new file mode 100644 index 0000000000..f1de48ee6d --- /dev/null +++ b/Blockzilla/SearchSuggestionsPromptView.swift @@ -0,0 +1,125 @@ +/* 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 SnapKit +import Telemetry + +protocol SearchSuggestionsPromptViewDelegate: class { + func searchSuggestionsPromptView(_ searchSuggestionsPromptView: SearchSuggestionsPromptView, didEnable: Bool) +} + +class SearchSuggestionsPromptView: UIView { + weak var delegate: SearchSuggestionsPromptViewDelegate? + static let respondedToSearchSuggestionsPrompt = "SearchSuggestionPrompt" + private let buttonBorderMiddle = UIView() + private let buttonBorderTop = UIView() + private let disableButton = InsetButton() + private let enableButton = InsetButton() + private let promptContainer = UIView() + private let promptMessage = UILabel() + private let promptTitle = UILabel() + + init() { + super.init(frame: CGRect.zero) + promptContainer.backgroundColor = UIConstants.Photon.Ink70.withAlphaComponent(0.9) + promptContainer.layer.cornerRadius = UIConstants.layout.searchSuggestionsPromptCornerRadius + addSubview(promptContainer) + + promptContainer.snp.makeConstraints{ make in + make.top.equalTo(self).offset(8).priority(.medium) + make.bottom.equalTo(self).offset(-8).priority(.medium) + make.leading.equalTo(self).offset(6) + make.trailing.equalTo(self).offset(-6) + } + + promptTitle.text = UIConstants.strings.searchSuggestionsPromptTitle + promptTitle.textColor = UIConstants.Photon.Grey10 + promptTitle.font = UIFont.systemFont(ofSize: 18, weight: UIFont.Weight.bold) + promptTitle.textAlignment = NSTextAlignment.center + promptTitle.numberOfLines = 0 + promptTitle.lineBreakMode = .byWordWrapping + promptContainer.addSubview(promptTitle) + + promptTitle.snp.makeConstraints { make in + make.top.equalTo(promptContainer).offset(20).priority(.medium) + make.leading.equalTo(promptContainer).offset(10) + make.trailing.equalTo(promptContainer).offset(-10) + } + + promptMessage.text = String(format: UIConstants.strings.searchSuggestionsPromptMessage, AppInfo.productName) + promptMessage.textColor = UIConstants.Photon.Grey10 + promptMessage.font = UIFont.systemFont(ofSize: 14) + promptMessage.textAlignment = NSTextAlignment.center + promptMessage.numberOfLines = 0 + promptMessage.lineBreakMode = .byWordWrapping + promptContainer.addSubview(promptMessage) + + promptMessage.snp.makeConstraints { make in + make.top.equalTo(promptTitle.snp.bottom).offset(5).priority(.medium) + make.leading.equalTo(promptContainer).offset(10) + make.trailing.equalTo(promptContainer).offset(-10) + } + + buttonBorderTop.backgroundColor = UIConstants.Photon.Grey10.withAlphaComponent(0.2) + addSubview(buttonBorderTop) + + buttonBorderTop.snp.makeConstraints { make in + make.top.equalTo(promptMessage.snp.bottom).offset(20).priority(.medium) + make.leading.trailing.equalTo(promptContainer) + make.height.equalTo(0.5).priority(.medium) + } + + buttonBorderMiddle.backgroundColor = UIConstants.Photon.Grey10.withAlphaComponent(0.2) + addSubview(buttonBorderMiddle) + + buttonBorderMiddle.snp.makeConstraints { make in + make.top.equalTo(buttonBorderTop.snp.bottom).priority(.medium) + make.bottom.equalTo(promptContainer).priority(.medium) + make.width.equalTo(0.5) + make.height.equalTo(40).priority(.medium) + make.centerX.equalTo(self) + } + + disableButton.accessibilityIdentifier = "SearchSuggestionsPromptView.disableButton" + disableButton.setTitle(UIConstants.strings.searchSuggestionsPromptDisable, for: .normal) + disableButton.titleLabel?.font = UIFont.systemFont(ofSize: 18) + disableButton.layer.cornerRadius = UIConstants.layout.searchSuggestionsPromptButtonRadius + disableButton.addTarget(self, action: #selector(didPressDisable), for: .touchUpInside) + addSubview(disableButton) + + disableButton.snp.makeConstraints { make in + make.top.equalTo(buttonBorderTop.snp.bottom) + make.bottom.leading.equalTo(promptContainer) + make.trailing.equalTo(buttonBorderMiddle.snp.leading) + } + + enableButton.accessibilityIdentifier = "SearchSuggestionsPromptView.enableButton" + enableButton.setTitle(UIConstants.strings.searchSuggestionsPromptEnable, for: .normal) + enableButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: UIFont.Weight.bold) + enableButton.layer.cornerRadius = UIConstants.layout.searchSuggestionsPromptButtonRadius + enableButton.addTarget(self, action: #selector(didPressEnable), for: .touchUpInside) + addSubview(enableButton) + + enableButton.snp.makeConstraints { make in + make.top.equalTo(buttonBorderTop.snp.bottom) + make.bottom.trailing.equalTo(promptContainer) + make.leading.equalTo(buttonBorderMiddle.snp.trailing) + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func didPressDisable() { + delegate?.searchSuggestionsPromptView(self, didEnable: false) + Telemetry.default.recordEvent(category: TelemetryEventCategory.action, method: TelemetryEventMethod.click, object: TelemetryEventObject.searchSuggestionsOff) + } + + @objc private func didPressEnable() { + delegate?.searchSuggestionsPromptView(self, didEnable: true) + Telemetry.default.recordEvent(category: TelemetryEventCategory.action, method: TelemetryEventMethod.click, object: TelemetryEventObject.searchSuggestionsOn) + } +} diff --git a/Blockzilla/SettingsViewController.swift b/Blockzilla/SettingsViewController.swift index 5890567e56..d6bfde9796 100644 --- a/Blockzilla/SettingsViewController.swift +++ b/Blockzilla/SettingsViewController.swift @@ -113,7 +113,7 @@ class SettingsViewController: UIViewController, UITableViewDataSource, UITableVi case .privacy: if BiometryType(context: LAContext()).hasBiometry { return 4 } return 3 - case .search: return 2 + case .search: return 3 case .siri: return 3 case .integration: return 1 case .mozilla: @@ -199,50 +199,37 @@ class SettingsViewController: UIViewController, UITableViewDataSource, UITableVi Section.getSections() }() - private var toggles = [Int : BlockerToggle]() + private var toggles = [Int : [Int : BlockerToggle]]() - private var initialToggles : [Int : BlockerToggle] { + private func getSectionIndex(_ section: Section) -> Int? { + return Section.getSections().index(where: { $0 == section }) + } + + private func initializeToggles() { let blockFontsToggle = BlockerToggle(label: UIConstants.strings.labelBlockFonts, setting: SettingsToggle.blockFonts) let usageDataSubtitle = String(format: UIConstants.strings.detailTextSendUsageData, AppInfo.productName) let usageDataToggle = BlockerToggle(label: UIConstants.strings.labelSendAnonymousUsageData, setting: SettingsToggle.sendAnonymousUsageData, subtitle: usageDataSubtitle) + let searchSuggestionSubtitle = String(format: UIConstants.strings.detailTextSearchSuggestion, AppInfo.productName) + let searchSuggestionToggle = BlockerToggle(label: UIConstants.strings.settingsSearchSuggestions, setting: SettingsToggle.enableSearchSuggestions, subtitle: searchSuggestionSubtitle) let safariToggle = BlockerToggle(label: UIConstants.strings.toggleSafari, setting: SettingsToggle.safari) let homeScreenTipsToggle = BlockerToggle(label: UIConstants.strings.toggleHomeScreenTips, setting: SettingsToggle.showHomeScreenTips) - var toggles = [Int : BlockerToggle]() - if let biometricToggle = createBiometricLoginToggleIfAvailable() { - toggles = [ - 1: blockFontsToggle, - 2: biometricToggle, - 3: usageDataToggle, - 6: safariToggle, - 7: homeScreenTipsToggle - ] - } else { - toggles = [ - 1: blockFontsToggle, - 2: usageDataToggle, - 5: safariToggle, - 6: homeScreenTipsToggle - ] - } - - if let safariRow = toggles.first(where: { $1 == safariToggle })?.key { - if !TipManager.shared.shouldShowTips() { - toggles.removeValue(forKey: safariRow + 1) - } - - if #available(iOS 12.0, *) { - toggles.removeValue(forKey: safariRow) - toggles[(safariRow + Section.siri.numberOfRows)] = safariToggle + if let privacyIndex = getSectionIndex(Section.privacy) { + if let biometricToggle = createBiometricLoginToggleIfAvailable() { + toggles[privacyIndex] = [1: blockFontsToggle, 2: biometricToggle, 3: usageDataToggle] + } else { + toggles[privacyIndex] = [1: blockFontsToggle, 2: usageDataToggle] } } - if #available(iOS 12.0, *) { - if let homeScreenTipsRow = toggles.first(where: { $1 == homeScreenTipsToggle })?.key { - toggles.removeValue(forKey: homeScreenTipsRow) - toggles[(homeScreenTipsRow + Section.siri.numberOfRows)] = homeScreenTipsToggle - } + if let searchIndex = getSectionIndex(Section.search) { + toggles[searchIndex] = [2: searchSuggestionToggle] + } + if let integrationIndex = getSectionIndex(Section.integration) { + toggles[integrationIndex] = [0: safariToggle] + } + if let mozillaIndex = getSectionIndex(Section.mozilla) { + toggles[mozillaIndex] = [0: homeScreenTipsToggle] } - return toggles } /// Used to calculate cell heights. @@ -288,6 +275,7 @@ class SettingsViewController: UIViewController, UITableViewDataSource, UITableVi let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(dismissSettings)) doneButton.tintColor = UIConstants.Photon.Magenta60 + doneButton.accessibilityIdentifier = "SettingsViewController.doneButton" navigationItem.leftBarButtonItem = doneButton highlightsButton = UIBarButtonItem(title: UIConstants.strings.whatsNewTitle, style: .plain, target: self, action: #selector(whatsNewClicked)) @@ -312,13 +300,15 @@ class SettingsViewController: UIViewController, UITableViewDataSource, UITableVi tableView.allowsSelection = true tableView.estimatedRowHeight = 44 - toggles = initialToggles - for (i, blockerToggle) in toggles { - let toggle = blockerToggle.toggle - toggle.onTintColor = UIConstants.colors.toggleOn - toggle.addTarget(self, action: #selector(toggleSwitched(_:)), for: .valueChanged) - toggle.isOn = Settings.getToggle(blockerToggle.setting) - toggles[i] = blockerToggle + initializeToggles() + for (sectionIndex, toggleArray) in toggles{ + for (cellIndex, blockerToggle) in toggleArray { + let toggle = blockerToggle.toggle + toggle.onTintColor = UIConstants.colors.toggleOn + toggle.addTarget(self, action: #selector(toggleSwitched(_:)), for: .valueChanged) + toggle.isOn = Settings.getToggle(blockerToggle.setting) + toggles[sectionIndex]?[cellIndex] = blockerToggle + } } NotificationCenter.default.addObserver(self, selector: #selector(applicationDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil) @@ -329,7 +319,7 @@ class SettingsViewController: UIViewController, UITableViewDataSource, UITableVi updateSafariEnabledState() tableView.reloadData() if shouldScrollToSiri { - guard let siriSection = sections.index(where: {$0 == Section.siri}) else { + guard let siriSection = getSectionIndex(Section.siri) else { shouldScrollToSiri = false return } @@ -375,48 +365,96 @@ class SettingsViewController: UIViewController, UITableViewDataSource, UITableVi return toggle } - fileprivate func toggleForIndexPath(_ indexPath: IndexPath) -> BlockerToggle { - var index = (indexPath as NSIndexPath).row - for i in 0..<(indexPath as NSIndexPath).section { - index += tableView.numberOfRows(inSection: i) - } - guard let toggle = toggles[index] else { return BlockerToggle(label: "Error", setting: SettingsToggle.blockAds) } + private func toggleForIndexPath(_ indexPath: IndexPath) -> BlockerToggle { + guard let toggle = toggles[indexPath.section]?[indexPath.row] + else { return BlockerToggle(label: "Error", setting: SettingsToggle.blockAds)} return toggle } - @objc func tappedLearnMoreFooter(gestureRecognizer: UIGestureRecognizer) { - guard let url = SupportUtils.URLForTopic(topic: "usage-data") else { return } + private func tappedFooter(topic: String) { + guard let url = SupportUtils.URLForTopic(topic: topic) else { return } let contentViewController = SettingsContentViewController(url: url) navigationController?.pushViewController(contentViewController, animated: true) } - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - func createToggleCell(for toggle: BlockerToggle) -> UITableViewCell { - let cell = SettingsTableViewCell(style: .subtitle, reuseIdentifier: "toggleCell") - cell.textLabel?.text = toggle.label - cell.textLabel?.numberOfLines = 0 - cell.accessoryView = PaddedSwitch(switchView: toggle.toggle) - cell.detailTextLabel?.text = toggle.subtitle - cell.detailTextLabel?.numberOfLines = 0 - cell.selectionStyle = .none - return cell + @objc func tappedLearnMoreFooter(gestureRecognizer: UIGestureRecognizer) { + tappedFooter(topic: UIConstants.strings.sumoTopicUsageData) + } + + @objc func tappedLearnMoreSearchSuggestionsFooter(gestureRecognizer: UIGestureRecognizer) { + tappedFooter(topic: UIConstants.strings.sumoTopicSearchSuggestion) + } + + private func getToggleCell(indexPath: IndexPath) -> UITableViewCell { + let cell: UITableViewCell = UITableViewCell(style: .subtitle, reuseIdentifier: "toggleCell") + let toggle = toggleForIndexPath(indexPath) + cell.textLabel?.text = toggle.label + cell.textLabel?.numberOfLines = 0 + cell.accessoryView = PaddedSwitch(switchView: toggle.toggle) + cell.detailTextLabel?.text = toggle.subtitle + cell.detailTextLabel?.numberOfLines = 0 + cell.selectionStyle = .none + //Add "Learn More" button recognition + if toggle.label == UIConstants.strings.labelSendAnonymousUsageData || + toggle.label == UIConstants.strings.settingsSearchSuggestions { + let selector = toggle.label == UIConstants.strings.labelSendAnonymousUsageData ? #selector(tappedLearnMoreFooter) : #selector(tappedLearnMoreSearchSuggestionsFooter) + let learnMoreButton = UIButton() + learnMoreButton.setTitle(UIConstants.strings.learnMore, for: UIControl.State.normal) + learnMoreButton.setTitleColor(UIConstants.colors.settingsLink, for: UIControl.State.normal) + if let cellFont = cell.detailTextLabel?.font { + learnMoreButton.titleLabel?.font = UIFont(name: cellFont.fontName,size: cellFont.pointSize) + } + let tapGesture = UITapGestureRecognizer(target: self, action: selector) + learnMoreButton.addGestureRecognizer(tapGesture) + cell.contentView.addSubview(learnMoreButton) + //Adjust the offsets to allow the button to fit. + cell.textLabel?.snp.makeConstraints { make in + make.top.equalToSuperview().offset(8) + make.left.equalToSuperview().offset(14) + } + cell.detailTextLabel?.snp.makeConstraints { make in + if let lineHeight = cell.textLabel?.font.lineHeight { + make.top.equalToSuperview().offset(10 + lineHeight) + make.left.equalToSuperview().offset(14) + make.trailing.equalToSuperview() + } + } + learnMoreButton.snp.makeConstraints { make in + make.left.equalToSuperview().offset(14) + make.bottom.equalToSuperview().offset(4) + } } - + cell.detailTextLabel?.text = toggle.subtitle + cell.detailTextLabel?.numberOfLines = 0 + return cell + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { var cell: UITableViewCell switch sections[indexPath.section] { + case .privacy: + if indexPath.row == 0 { + cell = SettingsTableViewCell(style: .subtitle, reuseIdentifier: "trackingCell") + cell.textLabel?.text = String(format: UIConstants.strings.trackingProtectionLabel) + cell.accessibilityIdentifier = "settingsViewController.trackingCell" + cell.accessoryType = .disclosureIndicator + } else { + cell = getToggleCell(indexPath: indexPath) + } case .search: - guard let searchCell = tableView.dequeueReusableCell(withIdentifier: "accessoryCell") as? SettingsTableViewAccessoryCell else { fatalError("Accessory cells do not exist") } - - let label = indexPath.row == 0 ? UIConstants.strings.settingsSearchLabel : UIConstants.strings.settingsAutocompleteSection - let autocompleteLabel = Settings.getToggle(.enableDomainAutocomplete) || Settings.getToggle(.enableCustomDomainAutocomplete) ? UIConstants.strings.autocompleteCustomEnabled : UIConstants.strings.autocompleteCustomDisabled - let accessoryLabel = indexPath.row == 0 ? searchEngineManager.activeEngine.name : autocompleteLabel - let identifier = indexPath.row == 0 ? "SettingsViewController.searchCell" : "SettingsViewController.autocompleteCell" - - searchCell.accessoryLabelText = accessoryLabel - searchCell.labelText = label - searchCell.accessibilityIdentifier = identifier - - cell = searchCell + if indexPath.row < 2 { + guard let searchCell = tableView.dequeueReusableCell(withIdentifier: "accessoryCell") as? SettingsTableViewAccessoryCell else { fatalError("Accessory cells do not exist") } + let autocompleteLabel = Settings.getToggle(.enableDomainAutocomplete) || Settings.getToggle(.enableCustomDomainAutocomplete) ? UIConstants.strings.autocompleteCustomEnabled : UIConstants.strings.autocompleteCustomDisabled + let labels : (label : String, accessoryLabel : String, identifier : String) = indexPath.row == 0 ? + (UIConstants.strings.settingsSearchLabel,searchEngineManager.activeEngine.name, "SettingsViewController.searchCell") + :(UIConstants.strings.settingsAutocompleteSection,autocompleteLabel,"SettingsViewController.autocompleteCell") + searchCell.accessoryLabelText = labels.accessoryLabel + searchCell.labelText = labels.label + searchCell.accessibilityIdentifier = labels.identifier + cell = searchCell + } else { + cell = getToggleCell(indexPath: indexPath) + } case .siri: guard #available(iOS 12.0, *), let siriCell = tableView.dequeueReusableCell(withIdentifier: "accessoryCell") as? SettingsTableViewAccessoryCell else { fatalError("No accessory cells") } if indexPath.row == 0 { @@ -441,8 +479,7 @@ class SettingsViewController: UIViewController, UITableViewDataSource, UITableVi cell = siriCell case .mozilla where TipManager.shared.shouldShowTips(): if indexPath.row == 0 { - let toggle = toggleForIndexPath(indexPath) - cell = createToggleCell(for: toggle) + cell = getToggleCell(indexPath: indexPath) } else if indexPath.row == 1 { cell = SettingsTableViewCell(style: .subtitle, reuseIdentifier: "aboutCell") cell.textLabel?.text = String(format: UIConstants.strings.aboutTitle, AppInfo.productName) @@ -463,28 +500,7 @@ class SettingsViewController: UIViewController, UITableViewDataSource, UITableVi cell.accessibilityIdentifier = "settingsViewController.rateFocus" } default: - if sections[indexPath.section] == .privacy && indexPath.row == 0 { - cell = SettingsTableViewCell(style: .subtitle, reuseIdentifier: "trackingCell") - cell.textLabel?.text = String(format: UIConstants.strings.trackingProtectionLabel) - cell.accessibilityIdentifier = "settingsViewController.trackingCell" - cell.accessoryType = .disclosureIndicator - } else { - let toggle = toggleForIndexPath(indexPath) - cell = createToggleCell(for: toggle) - - if toggle.label == UIConstants.strings.labelSendAnonymousUsageData { - let selector = #selector(tappedLearnMoreFooter) - let learnMore = NSAttributedString(string: UIConstants.strings.learnMore, attributes: [.foregroundColor : UIConstants.colors.settingsLink]) - let space = NSAttributedString(string: " ", attributes: [:]) - guard let subtitle = toggle.subtitle else { return cell } - let attributedSubtitle = NSMutableAttributedString(string: subtitle) - attributedSubtitle.append(space) - attributedSubtitle.append(learnMore) - cell.detailTextLabel?.attributedText = attributedSubtitle - let tapGesture = UITapGestureRecognizer(target: self, action: selector) - cell.addGestureRecognizer(tapGesture) - } - } + cell = getToggleCell(indexPath: indexPath) } cell.backgroundColor = UIConstants.colors.cellBackground @@ -508,7 +524,6 @@ class SettingsViewController: UIViewController, UITableViewDataSource, UITableVi func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { // Height for the Search Engine and Learn More row. - if indexPath.section == 0 { return UITableView.automaticDimension } if indexPath.section == 5 || (indexPath.section == 4 && indexPath.row >= 1) { return 44 @@ -525,6 +540,10 @@ class SettingsViewController: UIViewController, UITableViewDataSource, UITableVi var height = heightForLabel(dummyToggleCell.textLabel!, width: width, text: toggle.label) if let subtitle = toggle.subtitle { height += heightForLabel(dummyToggleCell.detailTextLabel!, width: width, text: subtitle) + if toggle.label == UIConstants.strings.labelSendAnonymousUsageData || + toggle.label == UIConstants.strings.settingsSearchSuggestions { + height += 10 + } } return height + 22 @@ -622,8 +641,8 @@ class SettingsViewController: UIViewController, UITableViewDataSource, UITableVi } private func updateSafariEnabledState() { - guard let safariIndex = toggles.first(where: { $1.setting == SettingsToggle.safari})?.key, - let safariToggle = toggles[safariIndex]?.toggle else { return } + guard let index = getSectionIndex(Section.integration), + let safariToggle = toggles[index]?[0]?.toggle else { return } safariToggle.isEnabled = false detector.detectEnabled(view) { [weak self] enabled in @@ -642,17 +661,16 @@ class SettingsViewController: UIViewController, UITableViewDataSource, UITableVi } @objc private func whatsNewClicked() { - highlightsButton?.tintColor = UIColor.white + highlightsButton?.tintColor = UIColor.white + guard let url = SupportUtils.URLForTopic(topic: UIConstants.strings.sumoTopicWhatsNew) else { return } - guard let versionNumber = AppInfo.shortVersion.first, - let url = SupportUtils.URLForTopic(topic: "whats-new-focus-ios-" + "\(versionNumber)") else { return } navigationController?.pushViewController(SettingsContentViewController(url: url), animated: true) whatsNew.didShowWhatsNew() } @objc private func toggleSwitched(_ sender: UISwitch) { - let toggle = toggles.values.filter { $0.toggle == sender }.first! + let toggle = toggles.values.filter { $0.values.filter { $0.toggle == sender } != []}[0].values.filter { $0.toggle == sender }[0] func updateSetting() { let telemetryEvent = TelemetryEvent(category: TelemetryEventCategory.action, method: TelemetryEventMethod.change, object: "setting", value: toggle.setting.rawValue) @@ -678,15 +696,17 @@ class SettingsViewController: UIViewController, UITableViewDataSource, UITableVi let instructionsViewController = SafariInstructionsViewController() navigationController!.pushViewController(instructionsViewController, animated: true) updateSetting() - default: + case .enableSearchSuggestions: + UserDefaults.standard.set(true, forKey: SearchSuggestionsPromptView.respondedToSearchSuggestionsPrompt) updateSetting() - } - - // This update must occur after the setting has been updated to properly take effect. - if toggle.setting == .showHomeScreenTips { + case .showHomeScreenTips: + updateSetting() + // This update must occur after the setting has been updated to properly take effect. if let browserViewController = presentingViewController as? BrowserViewController { browserViewController.refreshTipsDisplay() } + default: + updateSetting() } } } diff --git a/Blockzilla/TelemetryIntegration.swift b/Blockzilla/TelemetryIntegration.swift index ad2260d4e8..5e61535e75 100644 --- a/Blockzilla/TelemetryIntegration.swift +++ b/Blockzilla/TelemetryIntegration.swift @@ -66,4 +66,8 @@ class TelemetryEventObject { public static let requestDesktopTip = "request_desktop_tip" public static let siriFavoriteTip = "siri_favorite_tip" public static let siriEraseTip = "siri_erase_tip" + public static let searchSuggestionsOn = "search_suggestions_on" + public static let searchSuggestionsOff = "search_suggestions_off" + public static let searchSuggestionSelected = "search_suggestion_selected" + public static let searchSuggestionNotSelected = "search_suggestion_not_selected" } diff --git a/Blockzilla/UIConstants.swift b/Blockzilla/UIConstants.swift index cd7c246e55..92d390ca9a 100644 --- a/Blockzilla/UIConstants.swift +++ b/Blockzilla/UIConstants.swift @@ -174,16 +174,19 @@ struct UIConstants { struct layout { static let browserToolbarDisabledOpacity: CGFloat = 0.3 static let browserToolbarHeight: CGFloat = 44 - static let copyButtonAnimationDuration: TimeInterval = 0.1 static let deleteAnimationDuration: TimeInterval = 0.25 static let alphaToZeroDeleteAnimationDuration: TimeInterval = deleteAnimationDuration * (2 / 3) static let displayKeyboardDeleteAnimationDuration: TimeInterval = deleteAnimationDuration * (1 / 3) static let lockIconInset: Float = 4 static let navigationDoneOffset: Float = -10 static let overlayAnimationDuration: TimeInterval = 0.25 + static let overlayButtonHeight: Int = 56 + static let smallDeviceMaxNumSuggestions: Int = 4 + static let largeDeviceMaxNumSuggestions: Int = 5 static let progressVisibilityAnimationDuration: TimeInterval = 0.25 static let searchButtonInset: CGFloat = 15 - static let searchButtonAnimationDuration: TimeInterval = 0.1 + static let searchSuggestionsPromptCornerRadius: CGFloat = 12 + static let searchSuggestionsPromptButtonRadius: CGFloat = 8 static let toastAnimationDuration: TimeInterval = 0.3 static let toastDuration: TimeInterval = 1.5 static let toolbarFadeAnimationDuration = 0.25 @@ -279,6 +282,7 @@ struct UIConstants { static let labelBlockFonts = NSLocalizedString("Settings.toggleBlockFonts", value: "Block Web fonts", comment: "Label for toggle on main screen") static let labelSendAnonymousUsageData = NSLocalizedString("Settings.toggleSendUsageData", value: "Send usage data", comment: "Label for Send Usage Data toggle on main screen") static let detailTextSendUsageData = NSLocalizedString("Settings.detailTextSendUsageData", value: "Mozilla strives to collect only what we need to provide and improve %@ for everyone.", comment: "Description associated to the Send Usage Data toggle on main screen. %@ is the app name (Focus/Klar)") + static let sumoTopicUsageData = NSLocalizedString("Settings.usageData", value: "usage-data", comment: "URL for the learn more of the send usage data toggle") static let openCancel = NSLocalizedString("Open.Cancel", value: "Cancel", comment: "Label in share alert to cancel the alert") static let openFirefox = NSLocalizedString("Open.Firefox", value: "Firefox (Private Browsing)", comment: "Label in share alert to open the URL in Firefox") static let openMore = NSLocalizedString("Open.More", value: "More", comment: "Label in share alert to open the full system share menu") @@ -289,12 +293,19 @@ struct UIConstants { static let safariInstructionsNotEnabled = String(format: NSLocalizedString("Safari.instructionsNotEnabled", value: "%@ is not enabled.", comment: "Error label when the blocker is not enabled, shown in the intro and main app when disabled"), AppInfo.productName) static let searchButton = NSLocalizedString("URL.searchLabel", value: "Search for %@", comment: "Label displayed for search button when typing in the URL bar") static let findInPageButton = NSLocalizedString("URL.findOnPageLabel", value: "Find in page: %@", comment: "Label displayed for find in page button when typing in the URL Bar. %@ is any text the user has typed into the URL bar that they want to find on the current page.") + static let searchSuggestionsPromptMessage = NSLocalizedString("SearchSuggestions.promptMessage", value: "To get suggestions, %@ needs to send what you type in the address bar to the search engine.", comment: "%@ is the name of the app (Focus / Klar). Label for search suggestions prompt message") + static let searchSuggestionsPromptTitle = NSLocalizedString("SearchSuggestions.promptTitle", value: "Show Search Suggestions?", comment: "Title for search suggestions prompt") + static let searchSuggestionsPromptDisable = NSLocalizedString("SearchSuggestions.promptDisable", value: "No", comment: "Label for disable option on search suggestions prompt") + static let searchSuggestionsPromptEnable = NSLocalizedString("SearchSuggestions.promptEnable", value: "Yes", comment: "Label for enable option on search suggestions prompt") static let addToAutocompleteButton = NSLocalizedString("URL.addToAutocompleteLabel", value: "Add link to autocomplete", comment: "Label displayed for button used as a shortcut to add a link to the list of URLs to autocomplete.") static let settingsBlockOtherMessage = NSLocalizedString("Settings.blockOtherMessage", value: "Blocking other content trackers may break some videos and Web pages.", comment: "Alert message shown when toggling the Content blocker") static let settingsBlockOtherNo = NSLocalizedString("Settings.blockOtherNo", value: "No, Thanks", comment: "Button label for declining Content blocker alert") static let settingsBlockOtherYes = NSLocalizedString("Settings.blockOtherYes", value: "I Understand", comment: "Button label for accepting Content blocker alert") static let settingsSearchTitle = NSLocalizedString("Settings.searchTitle2", value: "SEARCH", comment: "Title for the search selection screen") static let settingsSearchLabel = NSLocalizedString("Settings.searchLabel", value: "Search Engine", comment: "Label for the search engine in the search screen") + static let settingsSearchSuggestions = NSLocalizedString("Settings.searchSuggestions",value: "Get Search Suggestions",comment: "Label for the Search Suggestions toggle row") + static let detailTextSearchSuggestion = NSLocalizedString("Settings.detailTextSearchSuggestion", value: "%@ will send what you type in the address bar to your search engine.", comment: "Description associated to the Search Suggestions toggle on main screen. %@ is the app name (Focus/Klar)") + static let sumoTopicSearchSuggestion = NSLocalizedString("Settings.searchSuggestions", value: "search-suggestions-focus-ios", comment: "URL for the learn more of the search suggestions toggle") static let settingsAutocompleteSection = NSLocalizedString("Settings.autocompleteSection", value: "URL Autocomplete", comment: "Title for the URL Autocomplete row") static let settingsTitle = NSLocalizedString("Settings.screenTitle", value: "Settings", comment: "Title for settings screen") static let settingsToggleOtherSubtitle = NSLocalizedString("Settings.toggleOtherSubtitle", value: "May break some videos and Web pages", comment: "Label subtitle for toggle on main screen") @@ -433,6 +444,7 @@ struct UIConstants { static let siriEraseTipTitle = String(format: "Ask Siri to erase %@ history:", AppInfo.productName) static let siriEraseTipDescription = "Add Siri shortcut" static let shareTrackersTipTitle = "%@ trackers blocked so far" + static let sumoTopicWhatsNew = "whats-new-focus-ios-7" static let encodingNameUTF8 = "utf-8" static let googleAmpURLPrefix = "https://www.google.com/amp/s/" } diff --git a/Blockzilla/UIDeviceExtensions.swift b/Blockzilla/UIDeviceExtensions.swift new file mode 100644 index 0000000000..7ae9c428e3 --- /dev/null +++ b/Blockzilla/UIDeviceExtensions.swift @@ -0,0 +1,23 @@ +/* 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 + +extension UIDevice { + var modelName: String { + var systemInfo = utsname() + uname(&systemInfo) + let machineMirror = Mirror(reflecting: systemInfo.machine) + let identifier = machineMirror.children.reduce("") { identifier, element in + guard let value = element.value as? Int8, value != 0 else { return identifier } + return identifier + String(UnicodeScalar(UInt8(value))) + } + return identifier + } + + // Checks if device is an iPhone 5s, iPhone SE, or iPod Touch 6th gen + func isSmallDevice() -> Bool { + return UIDevice.current.modelName == "iPhone6,1" || UIDevice.current.modelName == "iPhone6,2" || UIDevice.current.modelName == "iPod7,1" || UIDevice.current.modelName == "iPhone8,4" + } +} diff --git a/Blockzilla/URLBar.swift b/Blockzilla/URLBar.swift index 0893b7f3b3..362aa29222 100644 --- a/Blockzilla/URLBar.swift +++ b/Blockzilla/URLBar.swift @@ -693,6 +693,7 @@ class URLBar: UIView { @objc private func didPressClear() { urlText.text = nil + userInputText = nil urlText.rightView?.isHidden = true delegate?.urlBar(self, didEnterText: "") } @@ -802,6 +803,11 @@ extension URLBar: AutocompleteTextFieldDelegate { userInputText = nil delegate?.urlBar(self, didSubmitText: autocompleteTextField.text ?? "") + + if Settings.getToggle(.enableSearchSuggestions) { + Telemetry.default.recordEvent(TelemetryEvent(category: TelemetryEventCategory.action, method: TelemetryEventMethod.click, object: TelemetryEventObject.searchSuggestionNotSelected)) + } + return true } diff --git a/ClientTests/SearchEngineTests.swift b/ClientTests/SearchEngineTests.swift new file mode 100644 index 0000000000..e64262e936 --- /dev/null +++ b/ClientTests/SearchEngineTests.swift @@ -0,0 +1,75 @@ +/* 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 XCTest + +#if FOCUS +@testable import Firefox_Focus +#else +@testable import Firefox_Klar +#endif + +class SearchEngineTests: XCTestCase { + private let engine = SearchEngineManager(prefs: UserDefaults.standard).activeEngine + private let client = SearchSuggestClient() + + private let SPECIAL_CHAR_SEARCH = "\"" + private let NORMAL_SEARCH = "example" + private let BEGIN_WITH_WHITE_SPACE_SEARCH = " example" + + override func setUp() { + super.setUp() + } + + override func tearDown() { + super.tearDown() + } + + func testSpecialCharacterQuery() { + let queryURL = engine.urlForQuery(SPECIAL_CHAR_SEARCH) + XCTAssertNotNil(queryURL) + } + + func testSpecialCharacterSearchSuggestions() { + let searchURL = engine.urlForSuggestions(SPECIAL_CHAR_SEARCH) + XCTAssertNotNil(searchURL) + } + + func testNormalQuery() { + let queryURL = engine.urlForQuery(NORMAL_SEARCH) + XCTAssertNotNil(queryURL) + } + + func testNormalSearchSuggestions() { + let searchURL = engine.urlForSuggestions(NORMAL_SEARCH) + XCTAssertNotNil(searchURL) + } + + func testBeginWithWhiteSpaceQuery() { + let normalQueryURL = engine.urlForQuery(NORMAL_SEARCH) + let testQueryURL = engine.urlForQuery(BEGIN_WITH_WHITE_SPACE_SEARCH) + XCTAssertEqual(normalQueryURL, testQueryURL) + } + + func testBeginWithWhiteSpaceSearchSuggestions() { + let normalSearchURL = engine.urlForSuggestions(NORMAL_SEARCH) + let testSearchURL = engine.urlForSuggestions(BEGIN_WITH_WHITE_SPACE_SEARCH) + XCTAssertEqual(normalSearchURL, testSearchURL) + } + + func testGetSuggestions() { + client.getSuggestions(NORMAL_SEARCH, callback: { response, error in + XCTAssertThrowsError(error) + XCTAssertNil(response) + }) + } + + func testResponseConsistency() { + let client = SearchSuggestClient() + client.getSuggestions(NORMAL_SEARCH, callback: { response, error in + XCTAssertThrowsError(error) + XCTAssertEqual(self.NORMAL_SEARCH, response?[0]) + }) + } +} diff --git a/Shared/Settings.swift b/Shared/Settings.swift index 766f4ee267..0b3d2a3724 100644 --- a/Shared/Settings.swift +++ b/Shared/Settings.swift @@ -16,6 +16,7 @@ enum SettingsToggle: String { case sendAnonymousUsageData = "SendAnonymousUsageData" case enableDomainAutocomplete = "enableDomainAutocomplete" case enableCustomDomainAutocomplete = "enableCustomDomainAutocomplete" + case enableSearchSuggestions = "enableSearchSuggestions" } struct Settings { @@ -37,6 +38,7 @@ struct Settings { case .sendAnonymousUsageData: return AppInfo.isKlar ? false : true case .enableDomainAutocomplete: return true case .enableCustomDomainAutocomplete: return true + case .enableSearchSuggestions: return false } } diff --git a/XCUITest/SearchProviderTest.swift b/XCUITest/SearchProviderTest.swift index a8c2e12185..fac1fa7626 100644 --- a/XCUITest/SearchProviderTest.swift +++ b/XCUITest/SearchProviderTest.swift @@ -87,6 +87,7 @@ class SearchProviderTest: BaseTestCase { private func doSearch(searchWord: String, provider: String) { let searchForText = "Search for " + searchWord let urlbarUrltextTextField = app.textFields["URLBar.urlText"] + let cancelButton = app.buttons["URLBar.cancelButton"] urlbarUrltextTextField.tap() urlbarUrltextTextField.typeText(searchWord) @@ -94,6 +95,7 @@ class SearchProviderTest: BaseTestCase { app.buttons[searchForText].tap() waitForWebPageLoad() + urlbarUrltextTextField.tap() // Check the correct site is reached switch provider { case "Google": @@ -116,6 +118,8 @@ class SearchProviderTest: BaseTestCase { default: XCTFail("Invalid Search Provider") } + + cancelButton.tap() } } diff --git a/XCUITest/SearchSuggestionsTest.swift b/XCUITest/SearchSuggestionsTest.swift new file mode 100644 index 0000000000..fc0e15c054 --- /dev/null +++ b/XCUITest/SearchSuggestionsTest.swift @@ -0,0 +1,161 @@ +/* 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 XCTest + +class SearchSuggestionsPromptTest: BaseTestCase { + + override func setUp() { + super.setUp() + dismissFirstRunUI() + } + + override func tearDown() { + app.terminate() + super.tearDown() + } + + func checkToggle(isOn: Bool) { + let targetValue = isOn ? "1" : "0" + XCTAssertEqual(app.tables.switches["BlockerToggle.enableSearchSuggestions"].value as! String, targetValue) + } + + func checkSuggestions() { + // Check search cells are displayed correctly + let firstSuggestion = app.buttons.matching(identifier: "OverlayView.searchButton").element(boundBy: 0) + waitforExistence(element: firstSuggestion) + waitforExistence(element: app.buttons.matching(identifier: "OverlayView.searchButton").element(boundBy: 1)) + waitforExistence(element: app.buttons.matching(identifier: "OverlayView.searchButton").element(boundBy: 2)) + waitforExistence(element: app.buttons.matching(identifier: "OverlayView.searchButton").element(boundBy: 3)) + + let predicate = NSPredicate(format: "label BEGINSWITH 'g'") + let predicateQuery = app.buttons.matching(predicate) + + // Confirm that we have at least four suggestions starting with "g" + XCTAssert(predicateQuery.count >= 4) + + // Check tapping on first suggestion leads to correct page + firstSuggestion.tap() + waitForValueContains(element: app.textFields["URLBar.urlText"], value: "g") + } + + func typeInURLBar(text: String) { + app.textFields["Search or enter address"].tap() + app.textFields["Search or enter address"].typeText(text) + } + + func checkToggleStartsOff() { + waitforHittable(element: app.buttons["Settings"]) + app.buttons["Settings"].tap() + checkToggle(isOn: false) + } + + func testEnableThroughPrompt() { + // Check search suggestions toggle is initially OFF + checkToggleStartsOff() + + // Activate prompt by typing in URL bar + app.buttons["SettingsViewController.doneButton"].tap() + typeInURLBar(text: "g") + + // Prompt should display + waitforExistence(element: app.otherElements["SearchSuggestionsPromptView"]) + + // Press enable + app.buttons["SearchSuggestionsPromptView.enableButton"].tap() + + // Ensure prompt disappears + waitforNoExistence(element: app.otherElements["SearchSuggestionsPromptView"]) + + // Ensure search suggestions are shown + checkSuggestions() + + // Check search suggestions toggle is ON + waitforHittable(element: app.buttons["HomeView.settingsButton"]) + app.buttons["HomeView.settingsButton"].tap() + checkToggle(isOn: true) + } + + func testDisableThroughPrompt() { + // Check search suggestions toggle is initially OFF + checkToggleStartsOff() + + // Activate prompt by typing in URL bar + app.buttons["SettingsViewController.doneButton"].tap() + typeInURLBar(text: "g") + + // Prompt should display + waitforExistence(element: app.otherElements["SearchSuggestionsPromptView"]) + + // Press disable + app.buttons["SearchSuggestionsPromptView.disableButton"].tap() + + // Ensure prompt disappears + waitforNoExistence(element: app.otherElements["SearchSuggestionsPromptView"]) + + // Ensure only one search cell is shown + let suggestion = app.buttons.matching(identifier: "OverlayView.searchButton").element(boundBy: 0) + waitforExistence(element: suggestion) + XCTAssertEqual("Search for g", suggestion.label) + + // Check tapping on suggestion leads to correct page + suggestion.tap() + waitForValueContains(element: app.textFields["URLBar.urlText"], value: "g") + + // Check search suggestions toggle is OFF + waitforHittable(element: app.buttons["HomeView.settingsButton"]) + app.buttons["HomeView.settingsButton"].tap() + checkToggle(isOn: false) + } + + func testEnableThroughToggle() { + // Check search suggestions toggle is initially OFF + checkToggleStartsOff() + + // Turn toggle ON + app.tables.switches["BlockerToggle.enableSearchSuggestions"].tap() + + // Prompt should not display + app.buttons["SettingsViewController.doneButton"].tap() + typeInURLBar(text: "g") + waitforNoExistence(element: app.otherElements["SearchSuggestionsPromptView"]) + + // Ensure search suggestions are shown + checkSuggestions() + } + + func testEnableThenDisable() { + // Check search suggestions toggle is initially OFF + checkToggleStartsOff() + + // Activate prompt by typing in URL bar + app.buttons["SettingsViewController.doneButton"].tap() + typeInURLBar(text: "g") + + // Prompt should display + waitforExistence(element: app.otherElements["SearchSuggestionsPromptView"]) + + // Press enable + app.buttons["SearchSuggestionsPromptView.enableButton"].tap() + + // Ensure prompt disappears + waitforNoExistence(element: app.otherElements["SearchSuggestionsPromptView"]) + + // Ensure search suggestions are shown + checkSuggestions() + + // Disable through settings + waitforHittable(element: app.buttons["HomeView.settingsButton"]) + app.buttons["HomeView.settingsButton"].tap() + app.tables.switches["BlockerToggle.enableSearchSuggestions"].tap() + checkToggle(isOn: false) + + // Ensure only one search cell is shown + app.buttons["SettingsViewController.doneButton"].tap() + typeInURLBar(text: "g") + let suggestion = app.buttons.matching(identifier: "OverlayView.searchButton").element(boundBy: 0) + waitforExistence(element: suggestion) + XCTAssertEqual("Search for g", suggestion.label) + } +}