From 6110fd053226b5515195f8512b451fdd03acbcba Mon Sep 17 00:00:00 2001 From: Vanna Phong Date: Tue, 26 May 2020 12:53:53 -0700 Subject: [PATCH 01/14] Base BreachAlertsManager with loadBreaches() and compareToBreaches() (#6696) --- Client.xcodeproj/project.pbxproj | 4 + .../BreachAlertsManager.swift | 155 ++++++++++++++++++ .../LoginListViewController.swift | 7 + 3 files changed, 166 insertions(+) create mode 100644 Client/Frontend/Login Management/BreachAlertsManager.swift diff --git a/Client.xcodeproj/project.pbxproj b/Client.xcodeproj/project.pbxproj index ebfe0691ee88..5bb5a037f80d 100644 --- a/Client.xcodeproj/project.pbxproj +++ b/Client.xcodeproj/project.pbxproj @@ -365,6 +365,7 @@ C8F457AA1F1FDD9B000CB895 /* BrowserViewController+KeyCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8F457A91F1FDD9B000CB895 /* BrowserViewController+KeyCommands.swift */; }; C8FB0C75232151BA00031088 /* AppMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8FB0C74232151BA00031088 /* AppMenu.swift */; }; C8FB0C782321523D00031088 /* PageActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8FB0C772321523D00031088 /* PageActionMenu.swift */; }; + CA77ABFD24773C92005079F9 /* BreachAlertsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA77ABF424772D98005079F9 /* BreachAlertsManager.swift */; }; CDB3BE8724746787009320EE /* FirefoxAccountSignInViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB3BE8624746787009320EE /* FirefoxAccountSignInViewController.swift */; }; CE7F11941F3CEEC800ABFC0B /* RemoteDevices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7F115E1F3CCEF900ABFC0B /* RemoteDevices.swift */; }; CEFA977E1FAA6B490016F365 /* SyncContentSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEFA977D1FAA6B490016F365 /* SyncContentSettingsViewController.swift */; }; @@ -1501,6 +1502,7 @@ C8F457A91F1FDD9B000CB895 /* BrowserViewController+KeyCommands.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "BrowserViewController+KeyCommands.swift"; sourceTree = ""; }; C8FB0C74232151BA00031088 /* AppMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMenu.swift; sourceTree = ""; }; C8FB0C772321523D00031088 /* PageActionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageActionMenu.swift; sourceTree = ""; }; + CA77ABF424772D98005079F9 /* BreachAlertsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreachAlertsManager.swift; sourceTree = ""; }; CDB3BE8624746787009320EE /* FirefoxAccountSignInViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirefoxAccountSignInViewController.swift; sourceTree = ""; }; CE7F115E1F3CCEF900ABFC0B /* RemoteDevices.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteDevices.swift; sourceTree = ""; }; CEFA977D1FAA6B490016F365 /* SyncContentSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncContentSettingsViewController.swift; sourceTree = ""; }; @@ -3125,6 +3127,7 @@ E63ED7D71BFCD9990097D08E /* LoginTableViewCell.swift */, E63ED8E01BFD25580097D08E /* LoginListViewController.swift */, E633E2D91C21EAF8001FFF6C /* LoginDetailViewController.swift */, + CA77ABF424772D98005079F9 /* BreachAlertsManager.swift */, ); path = "Login Management"; sourceTree = ""; @@ -5172,6 +5175,7 @@ 7BA0601B1C0F4DE200DFADB6 /* TabPeekViewController.swift in Sources */, 6669B5E2211418A200CA117B /* WebsiteDataSearchResultsViewController.swift in Sources */, E6D8D5E71B569D70009E5A58 /* BrowserTrayAnimators.swift in Sources */, + CA77ABFD24773C92005079F9 /* BreachAlertsManager.swift in Sources */, EBC4869E2195F58300CDA48D /* AboutHomeHandler.swift in Sources */, DDA24A431FD84D630098F159 /* DefaultSearchPrefs.swift in Sources */, E65075611E37F77D006961AC /* MenuHelper.swift in Sources */, diff --git a/Client/Frontend/Login Management/BreachAlertsManager.swift b/Client/Frontend/Login Management/BreachAlertsManager.swift new file mode 100644 index 000000000000..4238bf8eed82 --- /dev/null +++ b/Client/Frontend/Login Management/BreachAlertsManager.swift @@ -0,0 +1,155 @@ +// +// BreachAlertsManager.swift +// Client +// +// Created by Vanna Phong on 5/21/20. +// Copyright © 2020 Mozilla. All rights reserved. +// + +import Foundation +import Storage // or whichever module has the LoginsRecord class + +struct BreachRecord: Codable { + var name: String + var title: String + var domain: String + var breachDate: String + var addedDate: String + var modifiedDate: String + var pwnCount: Int + var description: String + var logoPath: String + var isVerified: Bool + var isFabricated: Bool + var isSensitive: Bool + var isRetired: Bool + var isSpamList: Bool + var logoUrl: String? +} + +/* principles: modular and flexible */ +// class so that we don't make copies of large vars that might slow things down +public class BreachAlertsManager { + + // + // MARK: - Variables and Properties + // + var dataTask: URLSessionDataTask? + var endpointURL = "https://monitor.firefox.com/hibp/breaches" + var breaches: [BreachRecord] = [] + + // + // MARK: - Internal Methods + // + /** + Loads breaches from Monitor endpoint. + */ + public func loadBreaches() { + guard let url = URL(string: endpointURL) else { + return + } + + dataTask?.cancel() + + dataTask = URLSession.shared.dataTask(with: url) { data, response, error in + guard self.validatedHTTPResponse(response) != nil else { + print("loadBreaches(): invalid HTTP response") + return + } + + if let error = error { + print("loadBreaches(): error: \(error)") + return + } + + guard let data = data, !data.isEmpty else { + print("loadBreaches(): invalid data") + return + } + + let decoder = JSONDecoder() + + // .convertFromPascalCase + decoder.keyDecodingStrategy = .custom { keys in + let key = keys.last! // make sure array not empty + + // string manipulation + let keyStr = key.stringValue + let pascalToCamel = keyStr.prefix(1).lowercased() + keyStr.dropFirst() + + // CodingKey conformity + let codingKeyType = type(of: key) + return codingKeyType.init(stringValue: pascalToCamel)! + + } + + if let decoded = try? decoder.decode([BreachRecord].self, from: data) { + DispatchQueue.main.async { + self.breaches = decoded + } + } + } + + dataTask?.resume() + + } + + + /** + Compares a list of logins to a list of breaches and returns breached logins. + @param logins: an array of Any login information classes. + Any is used for flexibility - if this file is placed in another project, + the function will be able to take any object structure in. + **/ + func compareToBreaches(_ logins: [Any]) { + + for entry in logins { + let login = entry as! LoginRecord; // typecast for ease of use + + for breach in breaches { + // host check + let loginHostURL = URL(string: login.hostname) + if loginHostURL?.baseDomain == breach.domain { + print("compareToBreaches(): breach: \(breach.domain)") + } + + // date check + let pwLastChanged = Date.init(timeIntervalSince1970: TimeInterval(login.timePasswordChanged)) + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + let breachDate = dateFormatter.date(from: breach.breachDate)! + + if pwLastChanged < breachDate { + print("compareToBreaches(): ⚠️ password exposed ⚠️: \(breach.breachDate)") + } + + } + + } + + print("compareToBreaches(): fin") + + } + + + // + // MARK: - Internal Methods + // + // From firefox-ios/Shared/NetworkUtils.swift + private func validatedHTTPResponse(_ response: URLResponse?, contentType: String? = nil, statusCode: Range? = nil) -> HTTPURLResponse? { + if let response = response as? HTTPURLResponse { + if let range = statusCode { + return range.contains(response.statusCode) ? response : nil + } + if let type = contentType { + if let responseType = response.allHeaderFields["Content-Type"] as? String { + return responseType.contains(type) ? response : nil + } + return nil + } + return response + } + return nil + } +} diff --git a/Client/Frontend/Login Management/LoginListViewController.swift b/Client/Frontend/Login Management/LoginListViewController.swift index f5526323a262..f9354691744f 100644 --- a/Client/Frontend/Login Management/LoginListViewController.swift +++ b/Client/Frontend/Login Management/LoginListViewController.swift @@ -29,6 +29,7 @@ private extension UITableView { private let CellReuseIdentifier = "cell-reuse-id" private let SectionHeaderId = "section-header-id" private let LoginsSettingsSection = 0 +private let breachAlertsManager = BreachAlertsManager() class LoginListViewController: SensitiveViewController { @@ -250,6 +251,12 @@ class LoginListViewController: SensitiveViewController { let deferred = Deferred>() profile.logins.searchLoginsWithQuery(query) >>== { logins in deferred.fillIfUnfilled(Maybe(success: logins.asArray())) + + // consider taking logins to breachalertsmanager from here + // which indexes need a special layout? to display breached login + breachAlertsManager.loadBreaches() + breachAlertsManager.compareToBreaches(logins.asArray()) + succeed() } return deferred From 65f6e9d5d4deb56f8e870de24bc897e3680b96f0 Mon Sep 17 00:00:00 2001 From: Vanna Phong Date: Tue, 26 May 2020 13:34:10 -0700 Subject: [PATCH 02/14] Revert "Base BreachAlertsManager with loadBreaches() and compareToBreaches() (#6696)" (#6698) This reverts commit 6110fd053226b5515195f8512b451fdd03acbcba. --- Client.xcodeproj/project.pbxproj | 4 - .../BreachAlertsManager.swift | 155 ------------------ .../LoginListViewController.swift | 7 - 3 files changed, 166 deletions(-) delete mode 100644 Client/Frontend/Login Management/BreachAlertsManager.swift diff --git a/Client.xcodeproj/project.pbxproj b/Client.xcodeproj/project.pbxproj index 5bb5a037f80d..ebfe0691ee88 100644 --- a/Client.xcodeproj/project.pbxproj +++ b/Client.xcodeproj/project.pbxproj @@ -365,7 +365,6 @@ C8F457AA1F1FDD9B000CB895 /* BrowserViewController+KeyCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8F457A91F1FDD9B000CB895 /* BrowserViewController+KeyCommands.swift */; }; C8FB0C75232151BA00031088 /* AppMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8FB0C74232151BA00031088 /* AppMenu.swift */; }; C8FB0C782321523D00031088 /* PageActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8FB0C772321523D00031088 /* PageActionMenu.swift */; }; - CA77ABFD24773C92005079F9 /* BreachAlertsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA77ABF424772D98005079F9 /* BreachAlertsManager.swift */; }; CDB3BE8724746787009320EE /* FirefoxAccountSignInViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB3BE8624746787009320EE /* FirefoxAccountSignInViewController.swift */; }; CE7F11941F3CEEC800ABFC0B /* RemoteDevices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7F115E1F3CCEF900ABFC0B /* RemoteDevices.swift */; }; CEFA977E1FAA6B490016F365 /* SyncContentSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEFA977D1FAA6B490016F365 /* SyncContentSettingsViewController.swift */; }; @@ -1502,7 +1501,6 @@ C8F457A91F1FDD9B000CB895 /* BrowserViewController+KeyCommands.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "BrowserViewController+KeyCommands.swift"; sourceTree = ""; }; C8FB0C74232151BA00031088 /* AppMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMenu.swift; sourceTree = ""; }; C8FB0C772321523D00031088 /* PageActionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageActionMenu.swift; sourceTree = ""; }; - CA77ABF424772D98005079F9 /* BreachAlertsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreachAlertsManager.swift; sourceTree = ""; }; CDB3BE8624746787009320EE /* FirefoxAccountSignInViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirefoxAccountSignInViewController.swift; sourceTree = ""; }; CE7F115E1F3CCEF900ABFC0B /* RemoteDevices.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteDevices.swift; sourceTree = ""; }; CEFA977D1FAA6B490016F365 /* SyncContentSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncContentSettingsViewController.swift; sourceTree = ""; }; @@ -3127,7 +3125,6 @@ E63ED7D71BFCD9990097D08E /* LoginTableViewCell.swift */, E63ED8E01BFD25580097D08E /* LoginListViewController.swift */, E633E2D91C21EAF8001FFF6C /* LoginDetailViewController.swift */, - CA77ABF424772D98005079F9 /* BreachAlertsManager.swift */, ); path = "Login Management"; sourceTree = ""; @@ -5175,7 +5172,6 @@ 7BA0601B1C0F4DE200DFADB6 /* TabPeekViewController.swift in Sources */, 6669B5E2211418A200CA117B /* WebsiteDataSearchResultsViewController.swift in Sources */, E6D8D5E71B569D70009E5A58 /* BrowserTrayAnimators.swift in Sources */, - CA77ABFD24773C92005079F9 /* BreachAlertsManager.swift in Sources */, EBC4869E2195F58300CDA48D /* AboutHomeHandler.swift in Sources */, DDA24A431FD84D630098F159 /* DefaultSearchPrefs.swift in Sources */, E65075611E37F77D006961AC /* MenuHelper.swift in Sources */, diff --git a/Client/Frontend/Login Management/BreachAlertsManager.swift b/Client/Frontend/Login Management/BreachAlertsManager.swift deleted file mode 100644 index 4238bf8eed82..000000000000 --- a/Client/Frontend/Login Management/BreachAlertsManager.swift +++ /dev/null @@ -1,155 +0,0 @@ -// -// BreachAlertsManager.swift -// Client -// -// Created by Vanna Phong on 5/21/20. -// Copyright © 2020 Mozilla. All rights reserved. -// - -import Foundation -import Storage // or whichever module has the LoginsRecord class - -struct BreachRecord: Codable { - var name: String - var title: String - var domain: String - var breachDate: String - var addedDate: String - var modifiedDate: String - var pwnCount: Int - var description: String - var logoPath: String - var isVerified: Bool - var isFabricated: Bool - var isSensitive: Bool - var isRetired: Bool - var isSpamList: Bool - var logoUrl: String? -} - -/* principles: modular and flexible */ -// class so that we don't make copies of large vars that might slow things down -public class BreachAlertsManager { - - // - // MARK: - Variables and Properties - // - var dataTask: URLSessionDataTask? - var endpointURL = "https://monitor.firefox.com/hibp/breaches" - var breaches: [BreachRecord] = [] - - // - // MARK: - Internal Methods - // - /** - Loads breaches from Monitor endpoint. - */ - public func loadBreaches() { - guard let url = URL(string: endpointURL) else { - return - } - - dataTask?.cancel() - - dataTask = URLSession.shared.dataTask(with: url) { data, response, error in - guard self.validatedHTTPResponse(response) != nil else { - print("loadBreaches(): invalid HTTP response") - return - } - - if let error = error { - print("loadBreaches(): error: \(error)") - return - } - - guard let data = data, !data.isEmpty else { - print("loadBreaches(): invalid data") - return - } - - let decoder = JSONDecoder() - - // .convertFromPascalCase - decoder.keyDecodingStrategy = .custom { keys in - let key = keys.last! // make sure array not empty - - // string manipulation - let keyStr = key.stringValue - let pascalToCamel = keyStr.prefix(1).lowercased() + keyStr.dropFirst() - - // CodingKey conformity - let codingKeyType = type(of: key) - return codingKeyType.init(stringValue: pascalToCamel)! - - } - - if let decoded = try? decoder.decode([BreachRecord].self, from: data) { - DispatchQueue.main.async { - self.breaches = decoded - } - } - } - - dataTask?.resume() - - } - - - /** - Compares a list of logins to a list of breaches and returns breached logins. - @param logins: an array of Any login information classes. - Any is used for flexibility - if this file is placed in another project, - the function will be able to take any object structure in. - **/ - func compareToBreaches(_ logins: [Any]) { - - for entry in logins { - let login = entry as! LoginRecord; // typecast for ease of use - - for breach in breaches { - // host check - let loginHostURL = URL(string: login.hostname) - if loginHostURL?.baseDomain == breach.domain { - print("compareToBreaches(): breach: \(breach.domain)") - } - - // date check - let pwLastChanged = Date.init(timeIntervalSince1970: TimeInterval(login.timePasswordChanged)) - - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd" - let breachDate = dateFormatter.date(from: breach.breachDate)! - - if pwLastChanged < breachDate { - print("compareToBreaches(): ⚠️ password exposed ⚠️: \(breach.breachDate)") - } - - } - - } - - print("compareToBreaches(): fin") - - } - - - // - // MARK: - Internal Methods - // - // From firefox-ios/Shared/NetworkUtils.swift - private func validatedHTTPResponse(_ response: URLResponse?, contentType: String? = nil, statusCode: Range? = nil) -> HTTPURLResponse? { - if let response = response as? HTTPURLResponse { - if let range = statusCode { - return range.contains(response.statusCode) ? response : nil - } - if let type = contentType { - if let responseType = response.allHeaderFields["Content-Type"] as? String { - return responseType.contains(type) ? response : nil - } - return nil - } - return response - } - return nil - } -} diff --git a/Client/Frontend/Login Management/LoginListViewController.swift b/Client/Frontend/Login Management/LoginListViewController.swift index f9354691744f..f5526323a262 100644 --- a/Client/Frontend/Login Management/LoginListViewController.swift +++ b/Client/Frontend/Login Management/LoginListViewController.swift @@ -29,7 +29,6 @@ private extension UITableView { private let CellReuseIdentifier = "cell-reuse-id" private let SectionHeaderId = "section-header-id" private let LoginsSettingsSection = 0 -private let breachAlertsManager = BreachAlertsManager() class LoginListViewController: SensitiveViewController { @@ -251,12 +250,6 @@ class LoginListViewController: SensitiveViewController { let deferred = Deferred>() profile.logins.searchLoginsWithQuery(query) >>== { logins in deferred.fillIfUnfilled(Maybe(success: logins.asArray())) - - // consider taking logins to breachalertsmanager from here - // which indexes need a special layout? to display breached login - breachAlertsManager.loadBreaches() - breachAlertsManager.compareToBreaches(logins.asArray()) - succeed() } return deferred From 632582242f536a098301570760d060364b04d0c3 Mon Sep 17 00:00:00 2001 From: Vanna Phong Date: Thu, 28 May 2020 16:45:02 -0700 Subject: [PATCH 03/14] Base BreachAlertsManager class with loadBreaches() + compare() (#6699) --- Client.xcodeproj/project.pbxproj | 8 ++ .../Login Management/BreachAlertsClient.swift | 52 +++++++++++ .../BreachAlertsManager.swift | 92 +++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 Client/Frontend/Login Management/BreachAlertsClient.swift create mode 100644 Client/Frontend/Login Management/BreachAlertsManager.swift diff --git a/Client.xcodeproj/project.pbxproj b/Client.xcodeproj/project.pbxproj index ebfe0691ee88..c720aac04b73 100644 --- a/Client.xcodeproj/project.pbxproj +++ b/Client.xcodeproj/project.pbxproj @@ -365,6 +365,8 @@ C8F457AA1F1FDD9B000CB895 /* BrowserViewController+KeyCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8F457A91F1FDD9B000CB895 /* BrowserViewController+KeyCommands.swift */; }; C8FB0C75232151BA00031088 /* AppMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8FB0C74232151BA00031088 /* AppMenu.swift */; }; C8FB0C782321523D00031088 /* PageActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8FB0C772321523D00031088 /* PageActionMenu.swift */; }; + CA03B26A247F1D9E00382B62 /* BreachAlertsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA03B269247F1D9E00382B62 /* BreachAlertsClient.swift */; }; + CA77ABFD24773C92005079F9 /* BreachAlertsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA77ABF424772D98005079F9 /* BreachAlertsManager.swift */; }; CDB3BE8724746787009320EE /* FirefoxAccountSignInViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB3BE8624746787009320EE /* FirefoxAccountSignInViewController.swift */; }; CE7F11941F3CEEC800ABFC0B /* RemoteDevices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7F115E1F3CCEF900ABFC0B /* RemoteDevices.swift */; }; CEFA977E1FAA6B490016F365 /* SyncContentSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEFA977D1FAA6B490016F365 /* SyncContentSettingsViewController.swift */; }; @@ -1501,6 +1503,8 @@ C8F457A91F1FDD9B000CB895 /* BrowserViewController+KeyCommands.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "BrowserViewController+KeyCommands.swift"; sourceTree = ""; }; C8FB0C74232151BA00031088 /* AppMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMenu.swift; sourceTree = ""; }; C8FB0C772321523D00031088 /* PageActionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageActionMenu.swift; sourceTree = ""; }; + CA03B269247F1D9E00382B62 /* BreachAlertsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreachAlertsClient.swift; sourceTree = ""; }; + CA77ABF424772D98005079F9 /* BreachAlertsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreachAlertsManager.swift; sourceTree = ""; }; CDB3BE8624746787009320EE /* FirefoxAccountSignInViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirefoxAccountSignInViewController.swift; sourceTree = ""; }; CE7F115E1F3CCEF900ABFC0B /* RemoteDevices.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteDevices.swift; sourceTree = ""; }; CEFA977D1FAA6B490016F365 /* SyncContentSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncContentSettingsViewController.swift; sourceTree = ""; }; @@ -3125,6 +3129,8 @@ E63ED7D71BFCD9990097D08E /* LoginTableViewCell.swift */, E63ED8E01BFD25580097D08E /* LoginListViewController.swift */, E633E2D91C21EAF8001FFF6C /* LoginDetailViewController.swift */, + CA77ABF424772D98005079F9 /* BreachAlertsManager.swift */, + CA03B269247F1D9E00382B62 /* BreachAlertsClient.swift */, ); path = "Login Management"; sourceTree = ""; @@ -5049,6 +5055,7 @@ 31ADB5DA1E58CEC300E87909 /* ClipboardBarDisplayHandler.swift in Sources */, 745DAB3F1CDAB09E00D44181 /* HistoryBackButton.swift in Sources */, EB9A179D20E69A7F00B12184 /* Theme.swift in Sources */, + CA03B26A247F1D9E00382B62 /* BreachAlertsClient.swift in Sources */, 43DDB96C240995A70058A068 /* ETPModel.swift in Sources */, 274A36C9239EB94000A21587 /* LibraryPanelButton.swift in Sources */, 435D660523D794B90046EFA2 /* UpdateViewModel.swift in Sources */, @@ -5172,6 +5179,7 @@ 7BA0601B1C0F4DE200DFADB6 /* TabPeekViewController.swift in Sources */, 6669B5E2211418A200CA117B /* WebsiteDataSearchResultsViewController.swift in Sources */, E6D8D5E71B569D70009E5A58 /* BrowserTrayAnimators.swift in Sources */, + CA77ABFD24773C92005079F9 /* BreachAlertsManager.swift in Sources */, EBC4869E2195F58300CDA48D /* AboutHomeHandler.swift in Sources */, DDA24A431FD84D630098F159 /* DefaultSearchPrefs.swift in Sources */, E65075611E37F77D006961AC /* MenuHelper.swift in Sources */, diff --git a/Client/Frontend/Login Management/BreachAlertsClient.swift b/Client/Frontend/Login Management/BreachAlertsClient.swift new file mode 100644 index 000000000000..b1517430cba5 --- /dev/null +++ b/Client/Frontend/Login Management/BreachAlertsClient.swift @@ -0,0 +1,52 @@ +// +// BreachAlertsClient.swift +// Client +// +// Created by Vanna Phong on 5/27/20. +// Copyright © 2020 Mozilla. All rights reserved. +// + +import Foundation +import Shared + +/// Errors related to BreachAlertsClient and BreachAlertsManager. +struct BreachAlertsError: MaybeErrorType { + public let description: String +} +/// For mocking and testing BreachAlertsClient. +protocol BreachAlertsClientProtocol { + func fetchData(endpoint: BreachAlertsClient.Endpoint, completion: @escaping (_ result: Maybe) -> Void) +} + +/// Handles all network requests for BreachAlertsManager. +public class BreachAlertsClient: BreachAlertsClientProtocol { + private var dataTask: URLSessionDataTask? + public enum Endpoint: String { + case breachedAccounts = "https://monitor.firefox.com/hibp/breaches" + } + + /// Makes a network request to an endpoint and hands off the result to a completion handler. + public func fetchData(endpoint: Endpoint, completion: @escaping (_ result: Maybe) -> Void) { + // endpoint.rawValue is the url + guard let url = URL(string: endpoint.rawValue) else { + return + } + dataTask?.cancel() + dataTask = URLSession.shared.dataTask(with: url) { data, response, error in + guard validatedHTTPResponse(response) != nil else { + completion(Maybe(failure: BreachAlertsError(description: "invalid HTTP response"))) + return + } + if let error = error { + completion(Maybe(failure: BreachAlertsError(description: error.localizedDescription))) + return + } + guard let data = data, !data.isEmpty else { + completion(Maybe(failure: BreachAlertsError(description: "empty data"))) + return + } + completion(Maybe(success: data)) + } + dataTask?.resume() + } +} diff --git a/Client/Frontend/Login Management/BreachAlertsManager.swift b/Client/Frontend/Login Management/BreachAlertsManager.swift new file mode 100644 index 000000000000..3f8fc45440d6 --- /dev/null +++ b/Client/Frontend/Login Management/BreachAlertsManager.swift @@ -0,0 +1,92 @@ +// +// BreachAlertsManager.swift +// Client +// +// Created by Vanna Phong on 5/21/20. +// Copyright © 2020 Mozilla. All rights reserved. +// + +import Foundation +import Storage // or whichever module has the LoginsRecord class +import Shared // or whichever module has the Maybe class + +/// Breach structure decoded from JSON +struct BreachRecord: Codable { + var name: String + var title: String + var domain: String + var breachDate: String + var description: String + + enum CodingKeys: String, CodingKey { + case name = "Name" + case title = "Title" + case domain = "Domain" + case breachDate = "BreachDate" + case description = "Description" + } +} + +/// A manager for the user's breached login information, if any. +final public class BreachAlertsManager { + var breaches: [BreachRecord] = [] + var breachAlertsClient: BreachAlertsClientProtocol + + init(_ client: BreachAlertsClientProtocol = BreachAlertsClient()) { + self.breachAlertsClient = client + } + + /// Loads breaches from Monitor endpoint using BreachAlertsClient. + /// - Parameters: + /// - completion: a completion handler for the processed breaches + func loadBreaches(completion: @escaping (Maybe<[BreachRecord]>) -> Void) { + print("loadBreaches(): called") + + self.breachAlertsClient.fetchData(endpoint: .breachedAccounts) { maybeData in + if maybeData.isSuccess, let data = maybeData.successValue { + let decoder = JSONDecoder() + if let decoded = try? decoder.decode([BreachRecord].self, from: data) { + self.breaches = decoded + completion(Maybe(success: self.breaches)) + } + } else { + completion(Maybe(failure: BreachAlertsError(description: "failed to load breaches"))) + } + } + } + + /// Compares a list of logins to a list of breaches and returns breached logins. + /// - Parameters: + /// - logins: a list of logins to compare breaches to + func compareToBreaches(_ logins: [LoginRecord]) { + + if self.breaches.count <= 0 { + print("compareToBreaches(): empty breach list") + return + } + + // TODO: optimize this loop + for login in logins { + for breach in self.breaches { + // host check + let loginHostURL = URL(string: login.hostname) + if loginHostURL?.baseDomain == breach.domain { + print("compareToBreaches(): breach: \(breach.domain)") + + // date check + let pwLastChanged = Date.init(timeIntervalSince1970: TimeInterval(login.timePasswordChanged)) + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + let breachDate = dateFormatter.date(from: breach.breachDate) + print("compareToBreaches(): breach date: \(String(describing: breachDate))") + + if let breachDate = breachDate, pwLastChanged < breachDate { + print("compareToBreaches(): ⚠️ password exposed ⚠️: \(breach.breachDate)") + } + } + } + } + print("compareToBreaches(): fin") + } +} From 995fa21a03932c0cb7f20db44c875d42fa55c97d Mon Sep 17 00:00:00 2001 From: Vanna Phong Date: Mon, 1 Jun 2020 09:39:40 -0700 Subject: [PATCH 04/14] Test BreachAlertsManager.loadBreaches() and compareToBreaches() (#6715) --- Client.xcodeproj/project.pbxproj | 4 + .../Login Management/BreachAlertsClient.swift | 11 +-- .../BreachAlertsManager.swift | 47 ++++++----- ClientTests/BreachAlertsTests.swift | 79 +++++++++++++++++++ 4 files changed, 110 insertions(+), 31 deletions(-) create mode 100644 ClientTests/BreachAlertsTests.swift diff --git a/Client.xcodeproj/project.pbxproj b/Client.xcodeproj/project.pbxproj index c720aac04b73..a149b57af3b8 100644 --- a/Client.xcodeproj/project.pbxproj +++ b/Client.xcodeproj/project.pbxproj @@ -367,6 +367,7 @@ C8FB0C782321523D00031088 /* PageActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8FB0C772321523D00031088 /* PageActionMenu.swift */; }; CA03B26A247F1D9E00382B62 /* BreachAlertsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA03B269247F1D9E00382B62 /* BreachAlertsClient.swift */; }; CA77ABFD24773C92005079F9 /* BreachAlertsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA77ABF424772D98005079F9 /* BreachAlertsManager.swift */; }; + CA7BD568248189E800A0A61B /* BreachAlertsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA7BD564248185B500A0A61B /* BreachAlertsTests.swift */; }; CDB3BE8724746787009320EE /* FirefoxAccountSignInViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB3BE8624746787009320EE /* FirefoxAccountSignInViewController.swift */; }; CE7F11941F3CEEC800ABFC0B /* RemoteDevices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7F115E1F3CCEF900ABFC0B /* RemoteDevices.swift */; }; CEFA977E1FAA6B490016F365 /* SyncContentSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEFA977D1FAA6B490016F365 /* SyncContentSettingsViewController.swift */; }; @@ -1505,6 +1506,7 @@ C8FB0C772321523D00031088 /* PageActionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageActionMenu.swift; sourceTree = ""; }; CA03B269247F1D9E00382B62 /* BreachAlertsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreachAlertsClient.swift; sourceTree = ""; }; CA77ABF424772D98005079F9 /* BreachAlertsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreachAlertsManager.swift; sourceTree = ""; }; + CA7BD564248185B500A0A61B /* BreachAlertsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreachAlertsTests.swift; sourceTree = ""; }; CDB3BE8624746787009320EE /* FirefoxAccountSignInViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirefoxAccountSignInViewController.swift; sourceTree = ""; }; CE7F115E1F3CCEF900ABFC0B /* RemoteDevices.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteDevices.swift; sourceTree = ""; }; CEFA977D1FAA6B490016F365 /* SyncContentSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncContentSettingsViewController.swift; sourceTree = ""; }; @@ -3473,6 +3475,7 @@ 43446CF12412F9A800F5C643 /* ETPCoverSheet */, 43446CED2412DDB100F5C643 /* UpdateCoverSheet */, 8DCD3BCC1ED5B7FA00446D38 /* FxADeepLinkingTests.swift */, + CA7BD564248185B500A0A61B /* BreachAlertsTests.swift */, 3B6F40171DC7849C00656CC6 /* FirefoxHomeTests.swift */, F84B21D91A090F8100AAB793 /* ClientTests.swift */, 3B39EDB91E16E18900EF029F /* CustomSearchEnginesTest.swift */, @@ -5313,6 +5316,7 @@ 2FDB10931A9FBEC5006CF312 /* PrefsTests.swift in Sources */, D8EFFA261FF702A8001D3A09 /* NavigationRouterTests.swift in Sources */, 63306D452110BAF000F25400 /* TabManagerStoreTests.swift in Sources */, + CA7BD568248189E800A0A61B /* BreachAlertsTests.swift in Sources */, 4A59B58AD11B5EE1F80BBDEB /* TestHistory.swift in Sources */, A83E5B1D1C1DA8D80026D912 /* UIPasteboardExtensionsTests.swift in Sources */, 28D52E2F1BCDF53900187A1D /* ResetTests.swift in Sources */, diff --git a/Client/Frontend/Login Management/BreachAlertsClient.swift b/Client/Frontend/Login Management/BreachAlertsClient.swift index b1517430cba5..f8fcc8b59562 100644 --- a/Client/Frontend/Login Management/BreachAlertsClient.swift +++ b/Client/Frontend/Login Management/BreachAlertsClient.swift @@ -1,10 +1,6 @@ -// -// BreachAlertsClient.swift -// Client -// -// Created by Vanna Phong on 5/27/20. -// Copyright © 2020 Mozilla. All rights reserved. -// +/* 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 @@ -29,6 +25,7 @@ public class BreachAlertsClient: BreachAlertsClientProtocol { public func fetchData(endpoint: Endpoint, completion: @escaping (_ result: Maybe) -> Void) { // endpoint.rawValue is the url guard let url = URL(string: endpoint.rawValue) else { + completion(Maybe(failure: BreachAlertsError(description: "bad endpoint URL"))) return } dataTask?.cancel() diff --git a/Client/Frontend/Login Management/BreachAlertsManager.swift b/Client/Frontend/Login Management/BreachAlertsManager.swift index 3f8fc45440d6..5cb98f493e52 100644 --- a/Client/Frontend/Login Management/BreachAlertsManager.swift +++ b/Client/Frontend/Login Management/BreachAlertsManager.swift @@ -1,17 +1,13 @@ -// -// BreachAlertsManager.swift -// Client -// -// Created by Vanna Phong on 5/21/20. -// Copyright © 2020 Mozilla. All rights reserved. -// +/* 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 // or whichever module has the LoginsRecord class import Shared // or whichever module has the Maybe class /// Breach structure decoded from JSON -struct BreachRecord: Codable { +struct BreachRecord: Codable, Equatable { var name: String var title: String var domain: String @@ -43,26 +39,30 @@ final public class BreachAlertsManager { print("loadBreaches(): called") self.breachAlertsClient.fetchData(endpoint: .breachedAccounts) { maybeData in - if maybeData.isSuccess, let data = maybeData.successValue { - let decoder = JSONDecoder() - if let decoded = try? decoder.decode([BreachRecord].self, from: data) { - self.breaches = decoded - completion(Maybe(success: self.breaches)) - } - } else { - completion(Maybe(failure: BreachAlertsError(description: "failed to load breaches"))) + guard let data = maybeData.successValue else { + completion(Maybe(failure: BreachAlertsError(description: "failed to load breaches data"))) + return + } + guard let decoded = try? JSONDecoder().decode([BreachRecord].self, from: data) else { + completion(Maybe(failure: BreachAlertsError(description: "JSON data decode failure"))) + return } + + self.breaches = decoded + completion(Maybe(success: self.breaches)) } } /// Compares a list of logins to a list of breaches and returns breached logins. /// - Parameters: /// - logins: a list of logins to compare breaches to - func compareToBreaches(_ logins: [LoginRecord]) { + func compareToBreaches(_ logins: [LoginRecord]) -> Maybe<[LoginRecord]> { + var result: [LoginRecord] = [] if self.breaches.count <= 0 { - print("compareToBreaches(): empty breach list") - return + return Maybe(failure: BreachAlertsError(description: "cannot compare to an empty list of breaches")) + } else if logins.count <= 0 { + return Maybe(failure: BreachAlertsError(description: "cannot compare to an empty list of logins")) } // TODO: optimize this loop @@ -74,19 +74,18 @@ final public class BreachAlertsManager { print("compareToBreaches(): breach: \(breach.domain)") // date check - let pwLastChanged = Date.init(timeIntervalSince1970: TimeInterval(login.timePasswordChanged)) + let pwLastChanged = Date(timeIntervalSince1970: TimeInterval(login.timePasswordChanged)) let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" - let breachDate = dateFormatter.date(from: breach.breachDate) - print("compareToBreaches(): breach date: \(String(describing: breachDate))") - - if let breachDate = breachDate, pwLastChanged < breachDate { + if let breachDate = dateFormatter.date(from: breach.breachDate), pwLastChanged < breachDate { print("compareToBreaches(): ⚠️ password exposed ⚠️: \(breach.breachDate)") + result.append(login) } } } } print("compareToBreaches(): fin") + return Maybe(success: result) } } diff --git a/ClientTests/BreachAlertsTests.swift b/ClientTests/BreachAlertsTests.swift new file mode 100644 index 000000000000..78e63f9bd0b3 --- /dev/null +++ b/ClientTests/BreachAlertsTests.swift @@ -0,0 +1,79 @@ +/* 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/. */ + +@testable import Client +import Storage +import Shared +import XCTest + +let mockRecord = BreachRecord( + name: "MockBreach", + title: "A Mock BreachRecord", + domain: "breached.com", + breachDate: "1970-01-02", + description: "A mock BreachRecord for testing purposes." +) + +class MockBreachAlertsClient: BreachAlertsClientProtocol { + func fetchData(endpoint: BreachAlertsClient.Endpoint, completion: @escaping (Maybe) -> Void) { + guard let mockData = try? JSONEncoder().encode([mockRecord].self) else { + completion(Maybe(failure: BreachAlertsError(description: "failed to encode mockRecord"))) + return + } + completion(Maybe(success: mockData)) + } +} + +class BreachAlertsTests: XCTestCase { + var breachAlertsManager: BreachAlertsManager? + let unbreachedLogin = [ + LoginRecord(fromJSONDict: ["hostname" : "http://unbreached.com", "timePasswordChanged": 1590784648189]) + ] + let breachedLogin = [ + LoginRecord(fromJSONDict: ["hostname" : "http://breached.com", "timePasswordChanged": 1]) + ] + override func setUp() { + self.breachAlertsManager = BreachAlertsManager(MockBreachAlertsClient()) + } + /// Test for testing loadBreaches + func testDataRequest() { + breachAlertsManager?.loadBreaches { maybeBreaches in + XCTAssertTrue(maybeBreaches.isSuccess) + XCTAssertNotNil(maybeBreaches.successValue) + if let breaches = maybeBreaches.successValue { + XCTAssertEqual([mockRecord], breaches) + } + } + } + /// Test for testing compareBreaches + func testCompareBreaches() { + let unloadedBreachesOpt = self.breachAlertsManager?.compareToBreaches(breachedLogin) + XCTAssertNotNil(unloadedBreachesOpt) + if let unloadedBreaches = unloadedBreachesOpt { + XCTAssertTrue(unloadedBreaches.isFailure) + } + + breachAlertsManager?.loadBreaches { maybeBreachList in + let emptyLoginsOpt = self.breachAlertsManager?.compareToBreaches([]) + XCTAssertNotNil(emptyLoginsOpt) + if let emptyLogins = emptyLoginsOpt { + XCTAssertTrue(emptyLogins.isFailure) + } + + let noBreachesOpt = self.breachAlertsManager?.compareToBreaches(self.unbreachedLogin) + XCTAssertNotNil(noBreachesOpt) + if let noBreaches = noBreachesOpt { + XCTAssertTrue(noBreaches.isSuccess) + XCTAssertEqual(noBreaches.successValue?.count, 0) + } + + let breachedOpt = self.breachAlertsManager?.compareToBreaches(self.breachedLogin) + XCTAssertNotNil(breachedOpt) + if let breached = breachedOpt { + XCTAssertTrue(breached.isSuccess) + XCTAssertEqual(breached.successValue?.count, 1) + } + } + } +} From c0b16b0ac4f24f57752c9818b19af23fe1233143 Mon Sep 17 00:00:00 2001 From: Vanna Phong Date: Fri, 26 Jun 2020 10:44:27 -0700 Subject: [PATCH 05/14] Refactor LoginListViewController to MVVM (#6779) * Fix #6788: enable QR pairing for china edition (#6811) * Fix #6723: telemetry lib out-of-space fatal exception (#6813) Update the lib to v1.1.3 which has fix for this * [nobug, build] remove duplicate carthage copy-frameworks objcexc. step (#6832) * Bugzilla 1646756: close last priv tab clears the WKWebViewConfiguration (#6827) * Bugzilla 1646756: close last priv tab clears the WKWebViewConfiguration * use prefkey for blockpopups * Try installing Carthage (#6836) * Try installing Carthage modify carthage_command file add comment * remove space Co-authored-by: isabel rios * Update SnapshotHelper file (#6839) Co-authored-by: isabel rios * Fix #4717 - Provide feedback when pinning a site (#6785) * Fix #4717 - Provide feedback when pinning a site from inside the Library panel Display toast notification on the Library panel Display toast notification on the Bookmarks panel Display toast notification on the Website tab * Change to remove Co-authored-by: Vlad Dramaretskyi * Fixed #6730: open apps preview should hide all screens in private browsing (#6757) * Fixed #6730: open apps preview should hide all screens in private browsing * #6730 changed color of backdropContainer and changed it edges to fill whole screen, added it TrayView as well * Added new color #1D1133 for Ink90 * fixed the cut-off of copied link labels and the contrast of the color for light/dark mode * bug fixes : widget constraints + color contrasts of labels in light/dark themes + added pdf button designs * removed commented lines * styling edits * edit in constraint for widget button * removed tab + edits to constraints for buttons * fixed support for ios 112.4 colors * removed extra tabs * code styling * edits to label color variables definetions * code styling * code styling * code styling * Push subscription expired handling (#6851) * Use .afterFirstUnlock on all apnsToken instances * Re-register push on subscriptionExpired * Send dev in UA instead of 0.0.1 (#6849) The durable sync servers 503 if major version == 0 to sidestep a bug. * Revert "Fix: #6764 #6763 #6766 #6765 String cutting off in widget after copying link and color contrast of the copied link label" (#6860) * Revert "code styling" This reverts commit b1af71b251b2927f2ef14cd6c32f90e4eeef095d. * Revert "code styling" This reverts commit cd8a09b4ff74203954afe6d74b774a3a91fc2ce5. * Revert "code styling" This reverts commit a552d58f3c9f52aa52d3c387bbb11c19293c073f. * Revert "edits to label color variables definetions" This reverts commit 9c5da0a39d8d7092cf22fd9c62fab910a06fdb4e. * Revert "code styling" This reverts commit 911f098900a337c54506e004e11b3e2cd99f8b0a. * Revert "removed extra tabs" This reverts commit 9545675adcfffe8bcb60176a764874961c407b37. * Revert "fixed support for ios 112.4 colors" This reverts commit 5dd6c0c2d16fc4887d9f6edd0ec870f1dc64f19a. * Revert "removed tab + edits to constraints for buttons" This reverts commit 33f8d21481cff5e58104183ceb6c29e6258e64de. * Revert "edit in constraint for widget button" This reverts commit 48a429a5a857b002a752c9eb93e6f41028a69d3f. * Revert "styling edits" This reverts commit dd561acfaa0df945a9fa6e30c34fbc8648f6cb85. * Revert "removed commented lines" This reverts commit 408526718e09230ee9176eb66d8ad43db9ae4090. * Revert "bug fixes : widget constraints + color contrasts of labels in light/dark themes + added pdf button designs" This reverts commit 981ead9a5be63c8ad71aef7fcaf5ec29718261b5. * Revert "fixed the cut-off of copied link labels and the contrast of the color for light/dark mode" This reverts commit 8ec8a75648eab08b4a48334057dee17e2d5d3173. * Noorhashem/today widget fixes (#6861) * fixed the cut-off of copied link labels and the contrast of the color for light/dark mode * bug fixes : widget constraints + color contrasts of labels in light/dark themes + added pdf button designs * removed commented lines * styling edits * edit in constraint for widget button * removed tab + edits to constraints for buttons * fixed support for ios 112.4 colors * removed extra tabs * code styling * edits to label color variables definetions * code styling * code styling * code styling Co-authored-by: noorhashem * Fix #6862: UA test broken (#6863) Co-authored-by: Garvan Keeley Co-authored-by: isabelrios Co-authored-by: isabel rios Co-authored-by: Daniela Arcese Co-authored-by: Vlad Dramaretskyi Co-authored-by: Haris Zaman Co-authored-by: noorhashem Co-authored-by: Edouard Oger --- Account/FxAPushMessageHandler.swift | 2 +- Cartfile.resolved | 2 +- Client.xcodeproj/project.pbxproj | 1 - .../AppDelegate+PushNotifications.swift | 12 +- .../Browser/BrowserViewController.swift | 13 ++- Client/Frontend/Browser/TabManager.swift | 17 ++- .../Browser/TabTrayControllerV1.swift | 23 +++- Client/Frontend/Library/BookmarksPanel.swift | 6 +- Client/Frontend/Library/HistoryPanel.swift | 6 +- .../Settings/AppSettingsOptions.swift | 8 +- .../AppSettingsTableViewController.swift | 2 +- .../SettingsContentViewController.swift | 4 +- Client/Frontend/Strings.swift | 2 + .../PhotonActionSheet/PageActionMenu.swift | 15 ++- Client/Frontend/photon-colors.swift | 2 +- ClientTests/ClientTests.swift | 11 +- .../close-private-tabs.imageset/Contents.json | 21 ++++ .../close-private-tabs.pdf | Bin 0 -> 7999 bytes .../private-search.imageset/Contents.json | 15 +++ .../private-search.pdf | Bin 0 -> 7681 bytes .../search-button.imageset/Contents.json | 33 ++++++ .../search-button.imageset/search.pdf | Bin 0 -> 5969 bytes .../subtitleLableColor.colorset/Contents.json | 56 +++++++++ .../widgetLabelColors.colorset/Contents.json | 56 +++++++++ Extensions/Today/TodayViewController.swift | 108 ++++++++---------- Push/PushClient.swift | 2 +- RustFxA/PushNotificationSetup.swift | 2 +- Shared/Prefs.swift | 2 + Shared/UserAgent.swift | 8 +- XCUITests/PrivateBrowsingTest.swift | 25 ++++ XCUITests/WebPagesForTesting.swift | 2 +- buddybuild_carthage_command.sh | 11 +- fastlane/SnapshotHelper.swift | 45 ++------ test-fixtures/test-indexeddb-private.html | 37 ++++++ 34 files changed, 414 insertions(+), 135 deletions(-) create mode 100644 Extensions/Today/Images.xcassets/close-private-tabs.imageset/Contents.json create mode 100644 Extensions/Today/Images.xcassets/close-private-tabs.imageset/close-private-tabs.pdf create mode 100644 Extensions/Today/Images.xcassets/private-search.imageset/Contents.json create mode 100644 Extensions/Today/Images.xcassets/private-search.imageset/private-search.pdf create mode 100644 Extensions/Today/Images.xcassets/search-button.imageset/Contents.json create mode 100644 Extensions/Today/Images.xcassets/search-button.imageset/search.pdf create mode 100644 Extensions/Today/Images.xcassets/subtitleLableColor.colorset/Contents.json create mode 100644 Extensions/Today/Images.xcassets/widgetLabelColors.colorset/Contents.json create mode 100644 test-fixtures/test-indexeddb-private.html diff --git a/Account/FxAPushMessageHandler.swift b/Account/FxAPushMessageHandler.swift index fba2334b657e..889233ea2551 100644 --- a/Account/FxAPushMessageHandler.swift +++ b/Account/FxAPushMessageHandler.swift @@ -56,7 +56,7 @@ extension FxAPushMessageHandler { guard let string = plaintext else { // The app will detect this missing, and re-register. see AppDelegate+PushNotifications.swift. - keychain.removeObject(forKey: KeychainKey.apnsToken) + keychain.removeObject(forKey: KeychainKey.apnsToken, withAccessibility: .afterFirstUnlock) return deferMaybe(PushMessageError.notDecrypted) } diff --git a/Cartfile.resolved b/Cartfile.resolved index e20608293512..9a7ac512f18b 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -13,7 +13,7 @@ github "google/EarlGrey" "935df455f3937662575c868524ddd64078539741" github "jrendel/SwiftKeychainWrapper" "3.4.0" github "kif-framework/KIF" "v3.7.8" github "mozilla-mobile/MappaMundi" "1d17845e4bd6077d790aca5a2b4a468f19567934" -github "mozilla-mobile/telemetry-ios" "v1.1.2" +github "mozilla-mobile/telemetry-ios" "v1.1.3" github "mozilla-services/shavar-prod-lists" "1f282be9bc7bf86cf4d695e2a63dbcdf2eecfb89" github "mozilla/application-services" "v60.0.0" github "mozilla/glean" "v31.1.0" diff --git a/Client.xcodeproj/project.pbxproj b/Client.xcodeproj/project.pbxproj index 9f784a4215ca..2c13df69f42e 100644 --- a/Client.xcodeproj/project.pbxproj +++ b/Client.xcodeproj/project.pbxproj @@ -4598,7 +4598,6 @@ "$(SRCROOT)/Carthage/Build/iOS/Telemetry.framework", "$(SRCROOT)/Carthage/Build/iOS/SwiftProtobuf.framework", "$(SRCROOT)/Carthage/Build/iOS/Leanplum.framework", - "$(SRCROOT)/Carthage/Build/iOS/ObjcExceptionBridging.framework", "$(SRCROOT)/Carthage/Build/iOS/Glean.framework", ); name = "Copy Carthage Dependencies"; diff --git a/Client/Application/AppDelegate+PushNotifications.swift b/Client/Application/AppDelegate+PushNotifications.swift index a794bfa84431..73f23adaf11b 100644 --- a/Client/Application/AppDelegate+PushNotifications.swift +++ b/Client/Application/AppDelegate+PushNotifications.swift @@ -48,11 +48,21 @@ extension AppDelegate { } } + // If we see our local device with a pushEndpointExpired flag, clear the APNS token and re-register. + NotificationCenter.default.addObserver(forName: .constellationStateUpdate, object: nil, queue: nil) { notification in + if let newState = notification.userInfo?["newState"] as? ConstellationState { + if newState.localDevice?.subscriptionExpired ?? false { + KeychainWrapper.sharedAppContainerKeychain.removeObject(forKey: KeychainKey.apnsToken, withAccessibility: .afterFirstUnlock) + NotificationCenter.default.post(name: .RegisterForPushNotifications, object: nil) + } + } + } + // Use sync event as a periodic check for the apnsToken. // The notification service extension can clear this token if there is an error, and the main app can detect this and re-register. NotificationCenter.default.addObserver(forName: .ProfileDidStartSyncing, object: nil, queue: .main) { _ in let kc = KeychainWrapper.sharedAppContainerKeychain - if kc.object(forKey: KeychainKey.apnsToken) == nil { + if kc.object(forKey: KeychainKey.apnsToken, withAccessibility: .afterFirstUnlock) == nil { NotificationCenter.default.post(name: .RegisterForPushNotifications, object: nil) } } diff --git a/Client/Frontend/Browser/BrowserViewController.swift b/Client/Frontend/Browser/BrowserViewController.swift index 1e14dff480e7..3c677646894f 100644 --- a/Client/Frontend/Browser/BrowserViewController.swift +++ b/Client/Frontend/Browser/BrowserViewController.swift @@ -342,9 +342,11 @@ class BrowserViewController: UIViewController { return } + view.bringSubviewToFront(webViewContainerBackdrop) webViewContainerBackdrop.alpha = 1 webViewContainer.alpha = 0 urlBar.locationContainer.alpha = 0 + firefoxHomeViewController?.view.alpha = 0 topTabsViewController?.switchForegroundStatus(isInForeground: false) presentedViewController?.popoverPresentationController?.containerView?.alpha = 0 presentedViewController?.view.alpha = 0 @@ -356,12 +358,14 @@ class BrowserViewController: UIViewController { UIView.animate(withDuration: 0.2, delay: 0, options: UIView.AnimationOptions(), animations: { self.webViewContainer.alpha = 1 self.urlBar.locationContainer.alpha = 1 + self.firefoxHomeViewController?.view.alpha = 1 self.topTabsViewController?.switchForegroundStatus(isInForeground: true) self.presentedViewController?.popoverPresentationController?.containerView?.alpha = 1 self.presentedViewController?.view.alpha = 1 self.view.backgroundColor = UIColor.clear }, completion: { _ in self.webViewContainerBackdrop.alpha = 0 + self.view.sendSubviewToBack(self.webViewContainerBackdrop) }) // Re-show toolbar which might have been hidden during scrolling (prior to app moving into the background) @@ -376,7 +380,7 @@ class BrowserViewController: UIViewController { KeyboardHelper.defaultHelper.addDelegate(self) webViewContainerBackdrop = UIView() - webViewContainerBackdrop.backgroundColor = UIColor.Photon.Grey50 + webViewContainerBackdrop.backgroundColor = UIColor.Photon.Ink90 webViewContainerBackdrop.alpha = 0 view.addSubview(webViewContainerBackdrop) @@ -483,7 +487,7 @@ class BrowserViewController: UIViewController { } webViewContainerBackdrop.snp.makeConstraints { make in - make.edges.equalTo(webViewContainer) + make.edges.equalTo(self.view) } } @@ -539,7 +543,6 @@ class BrowserViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - // On iPhone, if we are about to show the On-Boarding, blank out the tab so that it does // not flash before we present. This change of alpha also participates in the animation when // the intro view is dismissed. @@ -2078,9 +2081,7 @@ extension BrowserViewController { object = .settings } - let signInVC = AppInfo.isChinaEdition ? - FxAWebViewController(pageType: .emailLoginFlow, profile: profile, dismissalStyle: .dismiss, deepLinkParams: deepLinkParams) : - FirefoxAccountSignInViewController(profile: profile, parentType: parentType, deepLinkParams: deepLinkParams) + let signInVC = FirefoxAccountSignInViewController(profile: profile, parentType: parentType, deepLinkParams: deepLinkParams) UnifiedTelemetry.recordEvent(category: .firefoxAccount, method: .view, object: object) return signInVC } diff --git a/Client/Frontend/Browser/TabManager.swift b/Client/Frontend/Browser/TabManager.swift index c989758c6a6a..f2195f16894f 100644 --- a/Client/Frontend/Browser/TabManager.swift +++ b/Client/Frontend/Browser/TabManager.swift @@ -63,9 +63,10 @@ class TabManager: NSObject { fileprivate let navDelegate: TabManagerNavDelegate - public static func makeWebViewConfig(isPrivate: Bool, blockPopups: Bool) -> WKWebViewConfiguration { + public static func makeWebViewConfig(isPrivate: Bool, prefs: Prefs?) -> WKWebViewConfiguration { let configuration = WKWebViewConfiguration() configuration.processPool = WKProcessPool() + let blockPopups = prefs?.boolForKey(PrefsKeys.KeyBlockPopups) ?? true configuration.preferences.javaScriptCanOpenWindowsAutomatically = !blockPopups // We do this to go against the configuration of the // tag to behave the same way as Safari :-( @@ -79,14 +80,12 @@ class TabManager: NSObject { // A WKWebViewConfiguration used for normal tabs lazy fileprivate var configuration: WKWebViewConfiguration = { - let blockPopups = profile.prefs.boolForKey("blockPopups") ?? true - return TabManager.makeWebViewConfig(isPrivate: false, blockPopups: blockPopups) + return TabManager.makeWebViewConfig(isPrivate: false, prefs: profile.prefs) }() // A WKWebViewConfiguration used for private mode tabs lazy fileprivate var privateConfiguration: WKWebViewConfiguration = { - let blockPopups = profile.prefs.boolForKey("blockPopups") ?? true - return TabManager.makeWebViewConfig(isPrivate: true, blockPopups: blockPopups) + return TabManager.makeWebViewConfig(isPrivate: true, prefs: profile.prefs) }() var selectedIndex: Int { return _selectedIndex } @@ -441,6 +440,10 @@ class TabManager: NSObject { tabs.remove(at: removalIndex) assert(count == prevCount - 1, "Make sure the tab count was actually removed") + if (tab.isPrivate && privateTabs.count < 1) { + privateConfiguration = TabManager.makeWebViewConfig(isPrivate: true, prefs: profile.prefs) + } + tab.closeAndRemovePrivateBrowsingData() if notify { @@ -474,6 +477,8 @@ class TabManager: NSObject { } privateTabs.forEach { $0.closeAndRemovePrivateBrowsingData() } tabs = normalTabs + + privateConfiguration = TabManager.makeWebViewConfig(isPrivate: true, prefs: profile.prefs) } func removeTabsWithUndoToast(_ tabs: [Tab]) { @@ -550,7 +555,7 @@ class TabManager: NSObject { @objc func prefsDidChange() { DispatchQueue.main.async { - let allowPopups = !(self.profile.prefs.boolForKey("blockPopups") ?? true) + let allowPopups = !(self.profile.prefs.boolForKey(PrefsKeys.KeyBlockPopups) ?? true) // Each tab may have its own configuration, so we should tell each of them in turn. for tab in self.tabs { tab.webView?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = allowPopups diff --git a/Client/Frontend/Browser/TabTrayControllerV1.swift b/Client/Frontend/Browser/TabTrayControllerV1.swift index 784cfaa0f1d3..1d4cf3162deb 100644 --- a/Client/Frontend/Browser/TabTrayControllerV1.swift +++ b/Client/Frontend/Browser/TabTrayControllerV1.swift @@ -45,6 +45,8 @@ class TabTrayControllerV1: UIViewController { var tabDisplayManager: TabDisplayManager! var tabCellIdentifer: TabDisplayer.TabCellIdentifer = TabCell.Identifier var otherBrowsingModeOffset = CGPoint.zero + // Backdrop used for displaying greyed background for private tabs + var webViewContainerBackdrop: UIView! var collectionView: UICollectionView! let statusBarBG = UIView() @@ -161,6 +163,10 @@ class TabTrayControllerV1: UIViewController { super.viewDidLoad() tabManager.addDelegate(self) view.accessibilityLabel = NSLocalizedString("Tabs Tray", comment: "Accessibility label for the Tabs Tray view.") + + webViewContainerBackdrop = UIView() + webViewContainerBackdrop.backgroundColor = UIColor.Photon.Ink90 + webViewContainerBackdrop.alpha = 0 collectionView.alwaysBounceVertical = true collectionView.backgroundColor = UIColor.theme.tabTray.background @@ -173,7 +179,7 @@ class TabTrayControllerV1: UIViewController { searchBarHolder.addSubview(roundedSearchBarHolder) searchBarHolder.addSubview(searchBar) searchBarHolder.backgroundColor = UIColor.theme.tabTray.toolbar - [collectionView, toolbar, searchBarHolder, cancelButton].forEach { view.addSubview($0) } + [webViewContainerBackdrop, collectionView, toolbar, searchBarHolder, cancelButton].forEach { view.addSubview($0) } makeConstraints() // The statusBar needs a background color @@ -222,6 +228,11 @@ class TabTrayControllerV1: UIViewController { } fileprivate func makeConstraints() { + + webViewContainerBackdrop.snp.makeConstraints { make in + make.edges.equalTo(self.view) + } + collectionView.snp.makeConstraints { make in make.left.equalTo(view.safeArea.left) make.right.equalTo(view.safeArea.right) @@ -518,20 +529,26 @@ extension TabTrayControllerV1 { extension TabTrayControllerV1 { @objc func appWillResignActiveNotification() { if tabDisplayManager.isPrivate { + webViewContainerBackdrop.alpha = 1 + view.bringSubviewToFront(webViewContainerBackdrop) collectionView.alpha = 0 searchBarHolder.alpha = 0 + emptyPrivateTabsView.alpha = 0 } } @objc func appDidBecomeActiveNotification() { // Re-show any components that might have been hidden because they were being displayed // as part of a private mode tab - UIView.animate(withDuration: 0.2) { + UIView.animate(withDuration: 0.2, animations: { self.collectionView.alpha = 1 - + self.emptyPrivateTabsView.alpha = 1 if self.tabDisplayManager.isPrivate, !self.privateTabsAreEmpty() { self.searchBarHolder.alpha = 1 } + }) { _ in + self.webViewContainerBackdrop.alpha = 0 + self.view.sendSubviewToBack(self.webViewContainerBackdrop) } } } diff --git a/Client/Frontend/Library/BookmarksPanel.swift b/Client/Frontend/Library/BookmarksPanel.swift index dcd9036f27c7..d45955252482 100644 --- a/Client/Frontend/Library/BookmarksPanel.swift +++ b/Client/Frontend/Library/BookmarksPanel.swift @@ -527,7 +527,11 @@ extension BookmarksPanel: LibraryPanelContextMenu { } let pinTopSite = PhotonActionSheetItem(title: Strings.PinTopsiteActionTitle, iconString: "action_pin", handler: { _, _ in - _ = self.profile.history.addPinnedTopSite(site) + _ = self.profile.history.addPinnedTopSite(site).uponQueue(.main) { result in + if result.isSuccess { + SimpleToast().showAlertWithText(Strings.AppMenuAddPinToTopSitesConfirmMessage, bottomContainer: self.view) + } + } }) actions.append(pinTopSite) diff --git a/Client/Frontend/Library/HistoryPanel.swift b/Client/Frontend/Library/HistoryPanel.swift index d1b8fc3808d2..82a5483b3070 100644 --- a/Client/Frontend/Library/HistoryPanel.swift +++ b/Client/Frontend/Library/HistoryPanel.swift @@ -232,7 +232,11 @@ class HistoryPanel: SiteTableViewController, LibraryPanel { } func pinToTopSites(_ site: Site) { - _ = profile.history.addPinnedTopSite(site).value + _ = profile.history.addPinnedTopSite(site).uponQueue(.main) { result in + if result.isSuccess { + SimpleToast().showAlertWithText(Strings.AppMenuAddPinToTopSitesConfirmMessage, bottomContainer: self.view) + } + } } func navigateToRecentlyClosed() { diff --git a/Client/Frontend/Settings/AppSettingsOptions.swift b/Client/Frontend/Settings/AppSettingsOptions.swift index 49950536f071..e5564d0f42a2 100644 --- a/Client/Frontend/Settings/AppSettingsOptions.swift +++ b/Client/Frontend/Settings/AppSettingsOptions.swift @@ -47,9 +47,7 @@ class ConnectSetting: WithoutAccountSetting { override var accessibilityIdentifier: String? { return "SignInToSync" } override func onClick(_ navigationController: UINavigationController?) { - let viewController = AppInfo.isChinaEdition ? - FxAWebViewController(pageType: .emailLoginFlow, profile: profile, dismissalStyle: .popToRootVC, deepLinkParams: nil) : - FirefoxAccountSignInViewController(profile: profile, parentType: .settings, deepLinkParams: nil) + let viewController = FirefoxAccountSignInViewController(profile: profile, parentType: .settings, deepLinkParams: nil) UnifiedTelemetry.recordEvent(category: .firefoxAccount, method: .view, object: .settings) navigationController?.pushViewController(viewController, animated: true) } @@ -340,9 +338,7 @@ class AccountStatusSetting: WithAccountSetting { override func onClick(_ navigationController: UINavigationController?) { guard !profile.rustFxA.accountNeedsReauth() else { - let vc = AppInfo.isChinaEdition ? - FxAWebViewController(pageType: .emailLoginFlow, profile: profile, dismissalStyle: .popToRootVC, deepLinkParams: nil) : - FirefoxAccountSignInViewController(profile: profile, parentType: .settings, deepLinkParams: nil) + let vc = FirefoxAccountSignInViewController(profile: profile, parentType: .settings, deepLinkParams: nil) UnifiedTelemetry.recordEvent(category: .firefoxAccount, method: .view, object: .settings) navigationController?.pushViewController(vc, animated: true) return diff --git a/Client/Frontend/Settings/AppSettingsTableViewController.swift b/Client/Frontend/Settings/AppSettingsTableViewController.swift index 3ca92f9fb150..1d7041b1e58e 100644 --- a/Client/Frontend/Settings/AppSettingsTableViewController.swift +++ b/Client/Frontend/Settings/AppSettingsTableViewController.swift @@ -48,7 +48,7 @@ class AppSettingsTableViewController: SettingsTableViewController { HomeSetting(settings: self), OpenWithSetting(settings: self), ThemeSetting(settings: self), - BoolSetting(prefs: prefs, prefKey: "blockPopups", defaultValue: true, + BoolSetting(prefs: prefs, prefKey: PrefsKeys.KeyBlockPopups, defaultValue: true, titleText: NSLocalizedString("Block Pop-up Windows", comment: "Block pop-up windows setting")), ] diff --git a/Client/Frontend/Settings/SettingsContentViewController.swift b/Client/Frontend/Settings/SettingsContentViewController.swift index 705e647a40e4..51ee5ed57013 100644 --- a/Client/Frontend/Settings/SettingsContentViewController.swift +++ b/Client/Frontend/Settings/SettingsContentViewController.swift @@ -107,7 +107,9 @@ class SettingsContentViewController: UIViewController, WKNavigationDelegate { } func makeWebView() -> WKWebView { - let config = TabManager.makeWebViewConfig(isPrivate: true, blockPopups: true) + let config = TabManager.makeWebViewConfig(isPrivate: true, prefs: nil) + config.preferences.javaScriptCanOpenWindowsAutomatically = false + let webView = WKWebView( frame: CGRect(width: 1, height: 1), configuration: config diff --git a/Client/Frontend/Strings.swift b/Client/Frontend/Strings.swift index fceca86cf78d..850767e4be7f 100644 --- a/Client/Frontend/Strings.swift +++ b/Client/Frontend/Strings.swift @@ -536,6 +536,8 @@ extension Strings { public static let AppMenuAddBookmarkConfirmMessage = NSLocalizedString("Menu.AddBookmark.Confirm", value: "Bookmark Added", comment: "Toast displayed to the user after a bookmark has been added.") public static let AppMenuTabSentConfirmMessage = NSLocalizedString("Menu.TabSent.Confirm", value: "Tab Sent", comment: "Toast displayed to the user after a tab has been sent successfully.") public static let AppMenuRemoveBookmarkConfirmMessage = NSLocalizedString("Menu.RemoveBookmark.Confirm", value: "Bookmark Removed", comment: "Toast displayed to the user after a bookmark has been removed.") + public static let AppMenuAddPinToTopSitesConfirmMessage = NSLocalizedString("Menu.AddPin.Confirm", value: "Pinned To Top Sites", comment: "Toast displayed to the user after adding the item to the Top Sites.") + public static let AppMenuRemovePinFromTopSitesConfirmMessage = NSLocalizedString("Menu.RemovePin.Confirm", value: "Removed From Top Sites", comment: "Toast displayed to the user after removing the item from the Top Sites.") public static let AppMenuAddToReadingListConfirmMessage = NSLocalizedString("Menu.AddToReadingList.Confirm", value: "Added To Reading List", comment: "Toast displayed to the user after adding the item to their reading list.") public static let SendToDeviceTitle = NSLocalizedString("Send to Device", tableName: "3DTouchActions", comment: "Label for preview action on Tab Tray Tab to send the current tab to another device") public static let PageActionMenuTitle = NSLocalizedString("Menu.PageActions.Title", value: "Page Actions", comment: "Label for title in page action menu.") diff --git a/Client/Frontend/Widgets/PhotonActionSheet/PageActionMenu.swift b/Client/Frontend/Widgets/PhotonActionSheet/PageActionMenu.swift index 9018517406e9..c70df4830e0c 100644 --- a/Client/Frontend/Widgets/PhotonActionSheet/PageActionMenu.swift +++ b/Client/Frontend/Widgets/PhotonActionSheet/PageActionMenu.swift @@ -11,6 +11,8 @@ enum ButtonToastAction { case bookmarkPage case removeBookmark case copyUrl + case pinPage + case removePinPage } extension PhotonActionSheetProtocol { @@ -97,9 +99,12 @@ extension PhotonActionSheetProtocol { guard let site = val.successValue?.asArray().first?.flatMap({ $0 }) else { return succeed() } - return self.profile.history.addPinnedTopSite(site) - }.uponQueue(.main) { _ in } + }.uponQueue(.main) { result in + if result.isSuccess { + success(Strings.AppMenuAddPinToTopSitesConfirmMessage, .pinPage) + } + } } let removeTopSitesPin = PhotonActionSheetItem(title: Strings.RemovePinTopsiteActionTitle, iconString: "action_unpin") { _, _ in @@ -111,7 +116,11 @@ extension PhotonActionSheetProtocol { } return self.profile.history.removeFromPinnedTopSites(site) - }.uponQueue(.main) { _ in } + }.uponQueue(.main) { result in + if result.isSuccess { + success(Strings.AppMenuRemovePinFromTopSitesConfirmMessage, .removePinPage) + } + } } let sendToDevice = PhotonActionSheetItem(title: Strings.SendToDeviceTitle, iconString: "menu-Send-to-Device") { _, _ in diff --git a/Client/Frontend/photon-colors.swift b/Client/Frontend/photon-colors.swift index 6e53cc1dedc7..bdf8f41bf121 100644 --- a/Client/Frontend/photon-colors.swift +++ b/Client/Frontend/photon-colors.swift @@ -92,7 +92,7 @@ extension UIColor { static let Ink60 = UIColor(rgb: 0x464B76) static let Ink70 = UIColor(rgb: 0x363959) static let Ink80 = UIColor(rgb: 0x202340) - static let Ink90 = UIColor(rgb: 0x0f1126) + static let Ink90 = UIColor(rgb: 0x1D1133) static let White100 = UIColor(rgb: 0xffffff) diff --git a/ClientTests/ClientTests.swift b/ClientTests/ClientTests.swift index 526f1df73d3e..c04f23b9f2ed 100644 --- a/ClientTests/ClientTests.swift +++ b/ClientTests/ClientTests.swift @@ -16,9 +16,14 @@ class ClientTests: XCTestCase { let ua = UserAgent.syncUserAgent let device = DeviceInfo.deviceModel() let systemVersion = UIDevice.current.systemVersion - let expectedRegex = "^Firefox-iOS-Sync/[0-9\\.]+b[0-9]* \\(\(device); iPhone OS \(systemVersion)\\) \\([-_A-Za-z0-9= \\(\\)]+\\)$" - let loc = ua.range(of: expectedRegex, options: .regularExpression) - XCTAssertTrue(loc != nil, "Sync UA is as expected. Was \(ua)") + + if AppInfo.appVersion != "0.0.1" { + let expectedRegex = "^Firefox-iOS-Sync/[0-9\\.]+b[0-9]* \\(\(device); iPhone OS \(systemVersion)\\) \\([-_A-Za-z0-9= \\(\\)]+\\)$" + let loc = ua.range(of: expectedRegex, options: .regularExpression) + XCTAssertTrue(loc != nil, "Sync UA is as expected. Was \(ua)") + } else { + XCTAssertTrue(ua.range(of: "dev") != nil) + } } func testMobileUserAgent() { diff --git a/Extensions/Today/Images.xcassets/close-private-tabs.imageset/Contents.json b/Extensions/Today/Images.xcassets/close-private-tabs.imageset/Contents.json new file mode 100644 index 000000000000..5dc3dae06cce --- /dev/null +++ b/Extensions/Today/Images.xcassets/close-private-tabs.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "close-private-tabs.pdf", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Extensions/Today/Images.xcassets/close-private-tabs.imageset/close-private-tabs.pdf b/Extensions/Today/Images.xcassets/close-private-tabs.imageset/close-private-tabs.pdf new file mode 100644 index 0000000000000000000000000000000000000000..7b9007f3ee234f95cb2e9799e1b92f6511a136a5 GIT binary patch literal 7999 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 literal 0 HcmV?d00001 diff --git a/Extensions/Today/Images.xcassets/subtitleLableColor.colorset/Contents.json b/Extensions/Today/Images.xcassets/subtitleLableColor.colorset/Contents.json new file mode 100644 index 000000000000..014c01ebf5b2 --- /dev/null +++ b/Extensions/Today/Images.xcassets/subtitleLableColor.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.235", + "green" : "0.220", + "red" : "0.220" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.702", + "green" : "0.694", + "red" : "0.694" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Extensions/Today/Images.xcassets/widgetLabelColors.colorset/Contents.json b/Extensions/Today/Images.xcassets/widgetLabelColors.colorset/Contents.json new file mode 100644 index 000000000000..a9cb0b1df4db --- /dev/null +++ b/Extensions/Today/Images.xcassets/widgetLabelColors.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.153", + "green" : "0.137", + "red" : "0.141" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.980", + "green" : "0.976", + "red" : "0.976" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Extensions/Today/TodayViewController.swift b/Extensions/Today/TodayViewController.swift index 20ba1311304b..02d4d3ce5809 100644 --- a/Extensions/Today/TodayViewController.swift +++ b/Extensions/Today/TodayViewController.swift @@ -17,40 +17,44 @@ struct TodayStrings { } private struct TodayUX { - static let privateBrowsingColor = UIColor(rgb: 0xcf68ff) static let backgroundHightlightColor = UIColor(white: 216.0/255.0, alpha: 44.0/255.0) - static let linkTextSize: CGFloat = 10.0 - static let labelTextSize: CGFloat = 14.0 - static let imageButtonTextSize: CGFloat = 14.0 - static let copyLinkImageWidth: CGFloat = 23 + static let linkTextSize: CGFloat = 9.0 + static let labelTextSize: CGFloat = 12.0 + static let imageButtonTextSize: CGFloat = 13.0 + static let copyLinkImageWidth: CGFloat = 20 static let margin: CGFloat = 8 static let buttonsHorizontalMarginPercentage: CGFloat = 0.1 - static let privateSearchButtonColorBrightPurple = UIColor(red: 117.0/255.0, green: 41.0/255.0, blue: 167.0/255.0, alpha: 1.0) - static let privateSearchButtonColorDarkPurple = UIColor(red: 73.0/255.0, green: 46.0/255.0, blue: 133.0/255.0, alpha: 1.0) - static let privateSearchButtonColorFaintDarkPurple = UIColor(red: 56.0/255.0, green: 51.0/255.0, blue: 114.0/255.0, alpha: 1.0) + static let buttonStackViewSpacing: CGFloat = 30.0 + static var labelColor: UIColor { + if #available(iOS 13, *) { + return UIColor(named: "widgetLabelColors") ?? UIColor(rgb: 0x242327) + } else { + return UIColor(rgb: 0x242327) + } + } + static var subtitleLabelColor: UIColor { + if #available(iOS 13, *) { + return UIColor(named: "subtitleLableColor") ?? UIColor(rgb: 0x38383C) + } else { + return UIColor(rgb: 0x38383C) + } + } } @objc (TodayViewController) class TodayViewController: UIViewController, NCWidgetProviding { - var copiedURL: URL? fileprivate lazy var newTabButton: ImageButtonWithLabel = { let imageButton = ImageButtonWithLabel() imageButton.addTarget(self, action: #selector(onPressNewTab), forControlEvents: .touchUpInside) imageButton.label.text = TodayStrings.NewTabButtonLabel - let button = imageButton.button - button.frame = CGRect(width: 60.0, height: 60.0) - button.backgroundColor = UIColor.white - button.layer.cornerRadius = button.frame.size.width/2 - button.clipsToBounds = true - button.setImage(UIImage(named: "search"), for: .normal) + button.setImage(UIImage(named: "search-button")?.withRenderingMode(.alwaysOriginal), for: .normal) let label = imageButton.label - label.tintColor = UIColor(named: "widgetLabelColors") - label.textColor = UIColor(named: "widgetLabelColors") + label.textColor = TodayUX.labelColor + label.tintColor = TodayUX.labelColor label.font = UIFont.systemFont(ofSize: TodayUX.imageButtonTextSize) - imageButton.sizeToFit() return imageButton }() @@ -60,16 +64,10 @@ class TodayViewController: UIViewController, NCWidgetProviding { imageButton.addTarget(self, action: #selector(onPressNewPrivateTab), forControlEvents: .touchUpInside) imageButton.label.text = TodayStrings.NewPrivateTabButtonLabel let button = imageButton.button - button.frame = CGRect(width: 60.0, height: 60.0) - button.performGradient(colorOne: TodayUX.privateSearchButtonColorFaintDarkPurple, colorTwo: TodayUX.privateSearchButtonColorDarkPurple, colorThree: TodayUX.privateSearchButtonColorBrightPurple) - button.layer.cornerRadius = button.frame.size.width/2 - button.clipsToBounds = true - button.setImage(UIImage(named: "quick_action_new_private_tab")?.withRenderingMode(.alwaysTemplate), for: .normal) - button.tintColor = UIColor.white - + button.setImage(UIImage(named: "private-search")?.withRenderingMode(.alwaysOriginal), for: .normal) let label = imageButton.label - label.tintColor = UIColor(named: "widgetLabelColors") - label.textColor = UIColor(named: "widgetLabelColors") + label.textColor = TodayUX.labelColor + label.tintColor = TodayUX.labelColor label.font = UIFont.systemFont(ofSize: TodayUX.imageButtonTextSize) imageButton.sizeToFit() return imageButton @@ -77,18 +75,18 @@ class TodayViewController: UIViewController, NCWidgetProviding { fileprivate lazy var openCopiedLinkButton: ButtonWithSublabel = { let button = ButtonWithSublabel() - button.setTitle(TodayStrings.GoToCopiedLinkLabel, for: .normal) button.addTarget(self, action: #selector(onPressOpenClibpoard), for: .touchUpInside) - // We need to set the background image/color for .Normal, so the whole button is tappable. button.setBackgroundColor(UIColor.clear, forState: .normal) button.setBackgroundColor(TodayUX.backgroundHightlightColor, forState: .highlighted) - - button.setImage(UIImage(named: "copy_link_icon")?.withRenderingMode(.alwaysTemplate), for: .normal) - + button.setImage(UIImage(named: "copy_link_icon")?.withRenderingMode(.alwaysOriginal), for: .normal) button.label.font = UIFont.systemFont(ofSize: TodayUX.labelTextSize) button.subtitleLabel.font = UIFont.systemFont(ofSize: TodayUX.linkTextSize) + button.label.textColor = TodayUX.labelColor + button.label.tintColor = TodayUX.labelColor + button.subtitleLabel.textColor = TodayUX.subtitleLabelColor + button.subtitleLabel.tintColor = TodayUX.subtitleLabelColor return button }() @@ -97,9 +95,7 @@ class TodayViewController: UIViewController, NCWidgetProviding { stackView.axis = .vertical stackView.alignment = .fill stackView.spacing = TodayUX.margin / 2 - stackView.distribution = UIStackView.Distribution.fill - stackView.layoutMargins = UIEdgeInsets(top: TodayUX.margin, left: TodayUX.margin, bottom: TodayUX.margin, right: TodayUX.margin) - stackView.isLayoutMarginsRelativeArrangement = true + stackView.distribution = UIStackView.Distribution.fillProportionally return stackView }() @@ -107,7 +103,7 @@ class TodayViewController: UIViewController, NCWidgetProviding { let stackView = UIStackView() stackView.axis = .horizontal stackView.alignment = .center - stackView.spacing = 30 + stackView.spacing = TodayUX.buttonStackViewSpacing stackView.distribution = UIStackView.Distribution.fillEqually return stackView }() @@ -122,15 +118,17 @@ class TodayViewController: UIViewController, NCWidgetProviding { override func viewDidLoad() { super.viewDidLoad() - let widgetView: UIView! self.extensionContext?.widgetLargestAvailableDisplayMode = .compact + let effectView: UIVisualEffectView + if #available(iOS 13, *) { effectView = UIVisualEffectView(effect: UIVibrancyEffect.widgetEffect(forVibrancyStyle: .label)) } else { - effectView = UIVisualEffectView(effect: UIVibrancyEffect.widgetPrimary()) + effectView = UIVisualEffectView(effect: .none) } + self.view.addSubview(effectView) effectView.snp.makeConstraints { make in make.edges.equalTo(self.view) @@ -217,20 +215,6 @@ extension UIButton { } } -extension UIButton { - func performGradient(colorOne: UIColor, colorTwo: UIColor, colorThree: UIColor) { - let gradientLayer = CAGradientLayer() - gradientLayer.frame = self.frame - gradientLayer.colors = [colorOne.cgColor, colorTwo.cgColor, colorThree.cgColor] - gradientLayer.startPoint = CGPoint(x: 1.0, y: 0.0) - gradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0) - gradientLayer.locations = [0.0, 0.5, 1.0] - gradientLayer.cornerRadius = self.frame.size.width/2 - layer.masksToBounds = true - layer.insertSublayer(gradientLayer, below: self.imageView?.layer) - } -} - class ImageButtonWithLabel: UIView { lazy var button = UIButton() @@ -248,18 +232,20 @@ class ImageButtonWithLabel: UIView { func performLayout() { addSubview(button) addSubview(label) + button.imageView?.contentMode = .scaleAspectFit button.snp.makeConstraints { make in make.centerX.equalTo(self) - make.top.equalTo(self.safeAreaLayoutGuide) - make.right.greaterThanOrEqualTo(self.safeAreaLayoutGuide).offset(30) - make.left.greaterThanOrEqualTo(self.safeAreaLayoutGuide).inset(30) - make.height.width.equalTo(60) + make.top.equalTo(self.safeAreaLayoutGuide).offset(5) + make.right.greaterThanOrEqualTo(self.safeAreaLayoutGuide).offset(40) + make.left.greaterThanOrEqualTo(self.safeAreaLayoutGuide).inset(40) + make.height.greaterThanOrEqualTo(60) } label.snp.makeConstraints { make in make.top.equalTo(button.snp.bottom).offset(10) make.leading.trailing.bottom.equalTo(self) + make.height.equalTo(10) } label.numberOfLines = 1 @@ -291,30 +277,30 @@ class ButtonWithSublabel: UIButton { fileprivate func performLayout() { let titleLabel = self.label - self.titleLabel?.removeFromSuperview() addSubview(titleLabel) let imageView = self.imageView! let subtitleLabel = self.subtitleLabel - subtitleLabel.textColor = UIColor.lightGray self.addSubview(subtitleLabel) imageView.snp.makeConstraints { make in - make.centerY.left.equalTo(self) + make.centerY.left.equalTo(10) make.width.equalTo(TodayUX.copyLinkImageWidth) } titleLabel.snp.makeConstraints { make in - make.left.equalTo(imageView.snp.right).offset(TodayUX.margin) + make.left.equalTo(imageView.snp.right).offset(10) make.trailing.top.equalTo(self) + make.height.greaterThanOrEqualTo(12) } subtitleLabel.lineBreakMode = .byTruncatingTail subtitleLabel.snp.makeConstraints { make in - make.bottom.equalTo(self) + make.bottom.equalTo(self).inset(10) make.top.equalTo(titleLabel.snp.bottom) make.leading.trailing.equalTo(titleLabel) + make.height.greaterThanOrEqualTo(10) } } diff --git a/Push/PushClient.swift b/Push/PushClient.swift index cc777f66286f..ac968920e509 100644 --- a/Push/PushClient.swift +++ b/Push/PushClient.swift @@ -128,7 +128,7 @@ public extension PushClient { mutableURLRequest.httpBody = JSON(parameters).stringify()?.utf8EncodedData return send(request: mutableURLRequest) >>== { json in - KeychainStore.shared.setString(apnsToken, forKey: KeychainKey.apnsToken) + KeychainStore.shared.setString(apnsToken, forKey: KeychainKey.apnsToken, withAccessibility: .afterFirstUnlock) return deferMaybe(creds) } } diff --git a/RustFxA/PushNotificationSetup.swift b/RustFxA/PushNotificationSetup.swift index 8e5bffa3ac3b..5b0203d1759c 100644 --- a/RustFxA/PushNotificationSetup.swift +++ b/RustFxA/PushNotificationSetup.swift @@ -14,7 +14,7 @@ open class PushNotificationSetup { // If we've already registered this push subscription, we don't need to do it again. let apnsToken = deviceToken.hexEncodedString let keychain = KeychainWrapper.sharedAppContainerKeychain - guard keychain.string(forKey: KeychainKey.apnsToken) != apnsToken else { + guard keychain.string(forKey: KeychainKey.apnsToken, withAccessibility: .afterFirstUnlock) != apnsToken else { return } diff --git a/Shared/Prefs.swift b/Shared/Prefs.swift index 8a5d2c25b22f..a93721d11db9 100644 --- a/Shared/Prefs.swift +++ b/Shared/Prefs.swift @@ -50,6 +50,8 @@ public struct PrefsKeys { public static let AppExtensionTelemetryOpenUrl = "AppExtensionTelemetryOpenUrl" public static let AppExtensionTelemetryEventArray = "AppExtensionTelemetryEvents" + + public static let KeyBlockPopups = "blockPopups" } public struct PrefsDefaults { diff --git a/Shared/UserAgent.swift b/Shared/UserAgent.swift index 0b945ecc71c7..3af4c0a85a5f 100644 --- a/Shared/UserAgent.swift +++ b/Shared/UserAgent.swift @@ -19,7 +19,13 @@ open class UserAgent { private static var defaults = UserDefaults(suiteName: AppInfo.sharedContainerIdentifier)! private static func clientUserAgent(prefix: String) -> String { - return "\(prefix)/\(AppInfo.appVersion)b\(AppInfo.buildNumber) (\(DeviceInfo.deviceModel()); iPhone OS \(UIDevice.current.systemVersion)) (\(AppInfo.displayName))" + let versionStr: String + if AppInfo.appVersion != "0.0.1" { + versionStr = "\(AppInfo.appVersion)b\(AppInfo.buildNumber)" + } else { + versionStr = "dev" + } + return "\(prefix)/\(versionStr) (\(DeviceInfo.deviceModel()); iPhone OS \(UIDevice.current.systemVersion)) (\(AppInfo.displayName))" } public static var syncUserAgent: String { diff --git a/XCUITests/PrivateBrowsingTest.swift b/XCUITests/PrivateBrowsingTest.swift index 994f29cd31b4..300464e61d4b 100644 --- a/XCUITests/PrivateBrowsingTest.swift +++ b/XCUITests/PrivateBrowsingTest.swift @@ -7,6 +7,7 @@ import XCTest let url1 = "example.com" let url2 = path(forTestPage: "test-mozilla-org.html") let url3 = path(forTestPage: "test-example.html") +let urlIndexedDB = path(forTestPage: "test-indexeddb-private.html") let url1And3Label = "Example Domain" let url2Label = "Internet for people, not profit — Mozilla" @@ -140,6 +141,30 @@ class PrivateBrowsingTest: BaseTestCase { checkOpenTabsAfterClosingPrivateMode() } + /* Loads a page that checks if an db file exists already. It uses indexedDB on both the main document, and in a web worker. + The loaded page has two staticTexts that get set when the db is correctly created (because the db didn't exist in the cache) + https://bugzilla.mozilla.org/show_bug.cgi?id=1646756 + */ + func testClearIndexedDB() { + enableClosePrivateBrowsingOptionWhenLeaving() + + func checkIndexedDBIsCreated() { + navigator.openURL(urlIndexedDB) + waitUntilPageLoad() + XCTAssertTrue(app.webViews.staticTexts["DB_CREATED_PAGE"].exists) + XCTAssertTrue(app.webViews.staticTexts["DB_CREATED_WORKER"].exists) + } + + navigator.toggleOn(userState.isPrivate, withAction: Action.TogglePrivateMode) + checkIndexedDBIsCreated() + + navigator.toggleOff(userState.isPrivate, withAction: Action.TogglePrivateMode) + checkIndexedDBIsCreated() + + navigator.toggleOn(userState.isPrivate, withAction: Action.TogglePrivateMode) + checkIndexedDBIsCreated() + } + func testPrivateBrowserPanelView() { // If no private tabs are open, there should be a initial screen with label Private Browsing navigator.toggleOn(userState.isPrivate, withAction: Action.TogglePrivateMode) diff --git a/XCUITests/WebPagesForTesting.swift b/XCUITests/WebPagesForTesting.swift index 20174898b66f..04ae01c62690 100644 --- a/XCUITests/WebPagesForTesting.swift +++ b/XCUITests/WebPagesForTesting.swift @@ -17,7 +17,7 @@ func registerHandlersForTestMethods(server: GCDWebServer) { return GCDWebServerDataResponse(html: "\(textNodes)") } - ["test-window-opener", "test-password", "test-password-submit", "test-password-2", "test-password-submit-2", "empty-login-form", "empty-login-form-submit", "test-example", "test-example-link", "test-mozilla-book", "test-mozilla-org", "test-popup-blocker", + ["test-indexeddb-private", "test-window-opener", "test-password", "test-password-submit", "test-password-2", "test-password-submit-2", "empty-login-form", "empty-login-form-submit", "test-example", "test-example-link", "test-mozilla-book", "test-mozilla-org", "test-popup-blocker", "manifesto-en", "manifesto-es", "manifesto-zh-CN", "manifesto-ar", "test-user-agent"].forEach { addHTMLFixture(name: $0, server: server) } diff --git a/buddybuild_carthage_command.sh b/buddybuild_carthage_command.sh index aa134b9e0a8d..c7e23152898f 100755 --- a/buddybuild_carthage_command.sh +++ b/buddybuild_carthage_command.sh @@ -1,2 +1,11 @@ #!/bin/bash -carthage bootstrap $CARTHAGE_VERBOSE --platform ios --color auto --cache-builds +#carthage bootstrap $CARTHAGE_VERBOSE --platform ios --color auto --cache-builds + +# Workaround to Carthage issue with latest version 0.35.0 +# https://github.com/Carthage/Carthage/issues/3003 +brew uninstall --force carthage +brew install https://github.com/Homebrew/homebrew-core/raw/09ceff6c1de7ebbfedb42c0941a48bfdca932c0f/Formula/carthage.rb + +carthage version + +carthage bootstrap $CARTHAGE_VERBOSE --platform ios diff --git a/fastlane/SnapshotHelper.swift b/fastlane/SnapshotHelper.swift index aaa2a9a9234f..04b0ddc18f59 100644 --- a/fastlane/SnapshotHelper.swift +++ b/fastlane/SnapshotHelper.swift @@ -38,22 +38,13 @@ func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) { } enum SnapshotError: Error, CustomDebugStringConvertible { - case cannotDetectUser - case cannotFindHomeDirectory case cannotFindSimulatorHomeDirectory - case cannotAccessSimulatorHomeDirectory(String) case cannotRunOnPhysicalDevice var debugDescription: String { switch self { - case .cannotDetectUser: - return "Couldn't find Snapshot configuration files - can't detect current user " - case .cannotFindHomeDirectory: - return "Couldn't find Snapshot configuration files - can't detect `Users` dir" case .cannotFindSimulatorHomeDirectory: return "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable." - case .cannotAccessSimulatorHomeDirectory(let simulatorHostHome): - return "Can't prepare environment. Simulator home location is inaccessible. Does \(simulatorHostHome) exist?" case .cannotRunOnPhysicalDevice: return "Can't use Snapshot on a physical device." } @@ -75,7 +66,7 @@ open class Snapshot: NSObject { Snapshot.waitForAnimations = waitForAnimations do { - let cacheDir = try pathPrefix() + let cacheDir = try getCacheDirectory() Snapshot.cacheDirectory = cacheDir setLanguage(app) setLocale(app) @@ -206,34 +197,22 @@ open class Snapshot: NSObject { _ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout) } - class func pathPrefix() throws -> URL? { - let homeDir: URL + class func getCacheDirectory() throws -> URL { + let cachePath = "Library/Caches/tools.fastlane" // on OSX config is stored in /Users//Library // and on iOS/tvOS/WatchOS it's in simulator's home dir #if os(OSX) - guard let user = ProcessInfo().environment["USER"] else { - throw SnapshotError.cannotDetectUser + let homeDir = URL(fileURLWithPath: NSHomeDirectory()) + return homeDir.appendingPathComponent(cachePath) + #elseif arch(i386) || arch(x86_64) + guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else { + throw SnapshotError.cannotFindSimulatorHomeDirectory } - - guard let usersDir = FileManager.default.urls(for: .userDirectory, in: .localDomainMask).first else { - throw SnapshotError.cannotFindHomeDirectory - } - - homeDir = usersDir.appendingPathComponent(user) + let homeDir = URL(fileURLWithPath: simulatorHostHome) + return homeDir.appendingPathComponent(cachePath) #else - #if arch(i386) || arch(x86_64) - guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else { - throw SnapshotError.cannotFindSimulatorHomeDirectory - } - guard let homeDirUrl = URL(string: simulatorHostHome) else { - throw SnapshotError.cannotAccessSimulatorHomeDirectory(simulatorHostHome) - } - homeDir = URL(fileURLWithPath: homeDirUrl.path) - #else - throw SnapshotError.cannotRunOnPhysicalDevice - #endif + throw SnapshotError.cannotRunOnPhysicalDevice #endif - return homeDir.appendingPathComponent("Library/Caches/tools.fastlane") } } @@ -300,4 +279,4 @@ private extension CGFloat { // Please don't remove the lines below // They are used to detect outdated configuration files -// SnapshotHelperVersion [1.21] +// SnapshotHelperVersion [1.22] diff --git a/test-fixtures/test-indexeddb-private.html b/test-fixtures/test-indexeddb-private.html new file mode 100644 index 000000000000..8693ca403e92 --- /dev/null +++ b/test-fixtures/test-indexeddb-private.html @@ -0,0 +1,37 @@ + + + + +
REPLACE_ME_PAGE
+
REPLACE_ME_WORKER
+ + + + + From 6d54ef8282e505f9a22d385834e208a13b7e1445 Mon Sep 17 00:00:00 2001 From: Vanna Phong Date: Tue, 30 Jun 2020 09:58:42 -0700 Subject: [PATCH 06/14] Refactor LoginListViewController to MVVM (again) (#6871) * Refactor LoginListViewController to MVVM * New PR made because previous changes were not merged correctly --- Client.xcodeproj/project.pbxproj | 20 + .../Login Management/BreachAlertsClient.swift | 4 +- .../BreachAlertsManager.swift | 6 +- .../Login Management/LoginDataSource.swift | 75 ++++ .../LoginListDataSourceHelper.swift | 78 ++++ .../LoginListSelectionHelper.swift | 46 +++ .../LoginListViewController.swift | 369 ++---------------- .../Login Management/LoginListViewModel.swift | 117 ++++++ .../Login Management/NoLoginsView.swift | 42 ++ ClientTests/BreachAlertsTests.swift | 8 +- 10 files changed, 419 insertions(+), 346 deletions(-) create mode 100644 Client/Frontend/Login Management/LoginDataSource.swift create mode 100644 Client/Frontend/Login Management/LoginListDataSourceHelper.swift create mode 100644 Client/Frontend/Login Management/LoginListSelectionHelper.swift create mode 100644 Client/Frontend/Login Management/LoginListViewModel.swift create mode 100644 Client/Frontend/Login Management/NoLoginsView.swift diff --git a/Client.xcodeproj/project.pbxproj b/Client.xcodeproj/project.pbxproj index 2c13df69f42e..eec3de4aeea2 100644 --- a/Client.xcodeproj/project.pbxproj +++ b/Client.xcodeproj/project.pbxproj @@ -367,8 +367,13 @@ C8FB0C75232151BA00031088 /* AppMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8FB0C74232151BA00031088 /* AppMenu.swift */; }; C8FB0C782321523D00031088 /* PageActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8FB0C772321523D00031088 /* PageActionMenu.swift */; }; CA03B26A247F1D9E00382B62 /* BreachAlertsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA03B269247F1D9E00382B62 /* BreachAlertsClient.swift */; }; + CA520E7A24913C1B00CCAB48 /* LoginListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA520E7924913C1B00CCAB48 /* LoginListViewModel.swift */; }; CA77ABFD24773C92005079F9 /* BreachAlertsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA77ABF424772D98005079F9 /* BreachAlertsManager.swift */; }; CA7BD568248189E800A0A61B /* BreachAlertsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA7BD564248185B500A0A61B /* BreachAlertsTests.swift */; }; + CA7FC7D324A6A9B70012F347 /* LoginListDataSourceHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA7FC7D224A6A9B70012F347 /* LoginListDataSourceHelper.swift */; }; + CA90753824929B22005B794D /* NoLoginsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA90753724929B22005B794D /* NoLoginsView.swift */; }; + CAA3B7E62497DCB60094E3C1 /* LoginDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA3B7E52497DCB60094E3C1 /* LoginDataSource.swift */; }; + CAC458F1249429C20042561A /* LoginListSelectionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC458F0249429C20042561A /* LoginListSelectionHelper.swift */; }; CDB3BE8724746787009320EE /* FirefoxAccountSignInViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB3BE8624746787009320EE /* FirefoxAccountSignInViewController.swift */; }; CE7F11941F3CEEC800ABFC0B /* RemoteDevices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7F115E1F3CCEF900ABFC0B /* RemoteDevices.swift */; }; CEFA977E1FAA6B490016F365 /* SyncContentSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEFA977D1FAA6B490016F365 /* SyncContentSettingsViewController.swift */; }; @@ -1507,8 +1512,13 @@ C8FB0C74232151BA00031088 /* AppMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMenu.swift; sourceTree = ""; }; C8FB0C772321523D00031088 /* PageActionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageActionMenu.swift; sourceTree = ""; }; CA03B269247F1D9E00382B62 /* BreachAlertsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreachAlertsClient.swift; sourceTree = ""; }; + CA520E7924913C1B00CCAB48 /* LoginListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginListViewModel.swift; sourceTree = ""; }; CA77ABF424772D98005079F9 /* BreachAlertsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreachAlertsManager.swift; sourceTree = ""; }; CA7BD564248185B500A0A61B /* BreachAlertsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreachAlertsTests.swift; sourceTree = ""; }; + CA7FC7D224A6A9B70012F347 /* LoginListDataSourceHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginListDataSourceHelper.swift; sourceTree = ""; }; + CA90753724929B22005B794D /* NoLoginsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoLoginsView.swift; sourceTree = ""; }; + CAA3B7E52497DCB60094E3C1 /* LoginDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginDataSource.swift; sourceTree = ""; }; + CAC458F0249429C20042561A /* LoginListSelectionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginListSelectionHelper.swift; sourceTree = ""; }; CDB3BE8624746787009320EE /* FirefoxAccountSignInViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirefoxAccountSignInViewController.swift; sourceTree = ""; }; CE7F115E1F3CCEF900ABFC0B /* RemoteDevices.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteDevices.swift; sourceTree = ""; }; CEFA977D1FAA6B490016F365 /* SyncContentSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncContentSettingsViewController.swift; sourceTree = ""; }; @@ -3141,6 +3151,11 @@ E63ED7D71BFCD9990097D08E /* LoginTableViewCell.swift */, E63ED8E01BFD25580097D08E /* LoginListViewController.swift */, E633E2D91C21EAF8001FFF6C /* LoginDetailViewController.swift */, + CA520E7924913C1B00CCAB48 /* LoginListViewModel.swift */, + CA90753724929B22005B794D /* NoLoginsView.swift */, + CAA3B7E52497DCB60094E3C1 /* LoginDataSource.swift */, + CAC458F0249429C20042561A /* LoginListSelectionHelper.swift */, + CA7FC7D224A6A9B70012F347 /* LoginListDataSourceHelper.swift */, CA77ABF424772D98005079F9 /* BreachAlertsManager.swift */, CA03B269247F1D9E00382B62 /* BreachAlertsClient.swift */, ); @@ -5079,6 +5094,7 @@ D38F02D11C05127100175932 /* Authenticator.swift in Sources */, E68E7ADC1CAC208200FDCA76 /* SetupPasscodeViewController.swift in Sources */, 3B61CD491F2A74EF00D38DE1 /* PocketFeed.swift in Sources */, + CAA3B7E62497DCB60094E3C1 /* LoginDataSource.swift in Sources */, 396E38F11EE0C8EC00CC180F /* FxAPushMessageHandler.swift in Sources */, E4CD9F6D1A77DD2800318571 /* ReaderModeStyleViewController.swift in Sources */, D0FCF7F51FE45842004A7995 /* UserScriptManager.swift in Sources */, @@ -5163,6 +5179,7 @@ E69E06C91C76198000D0F926 /* AuthenticationManagerConstants.swift in Sources */, 392ED7E61D0AEFEF009D9B62 /* HomePageAccessors.swift in Sources */, 7BA8D1C71BA037F500C8AE9E /* OpenInHelper.swift in Sources */, + CA90753824929B22005B794D /* NoLoginsView.swift in Sources */, E4B423BE1AB9FE6A007E66C8 /* ReaderModeCache.swift in Sources */, 396CDB55203C5B870034A3A3 /* TabTrayController+KeyCommands.swift in Sources */, 74E36D781B71323500D69DA1 /* SettingsContentViewController.swift in Sources */, @@ -5189,6 +5206,8 @@ D88FDAAF1F4E2BA000FD9709 /* PhotonActionSheetAnimator.swift in Sources */, E698FFDA1B4AADF40001F623 /* TabScrollController.swift in Sources */, D34510881ACF415700EC27F0 /* SearchLoader.swift in Sources */, + CA7FC7D324A6A9B70012F347 /* LoginListDataSourceHelper.swift in Sources */, + CA520E7A24913C1B00CCAB48 /* LoginListViewModel.swift in Sources */, 39CE74FB21A8513B007AE4F2 /* TranslationService.swift in Sources */, E65075521E37F6D1006961AC /* UIViewExtensions.swift in Sources */, C8F457A81F1FD75A000CB895 /* BrowserViewController+WebViewDelegates.swift in Sources */, @@ -5270,6 +5289,7 @@ 3B39EDCB1E16E1AA00EF029F /* CustomSearchViewController.swift in Sources */, E65075571E37F714006961AC /* FaviconFetcher.swift in Sources */, D863C8F21F68BFC20058D95F /* GradientProgressBar.swift in Sources */, + CAC458F1249429C20042561A /* LoginListSelectionHelper.swift in Sources */, EB9A178E20E525DF00B12184 /* ThemeSettingsController.swift in Sources */, D3C744CD1A687D6C004CE85D /* URIFixup.swift in Sources */, E4A961181AC041C40069AD6F /* ReadabilityService.swift in Sources */, diff --git a/Client/Frontend/Login Management/BreachAlertsClient.swift b/Client/Frontend/Login Management/BreachAlertsClient.swift index f8fcc8b59562..9b980928ed9b 100644 --- a/Client/Frontend/Login Management/BreachAlertsClient.swift +++ b/Client/Frontend/Login Management/BreachAlertsClient.swift @@ -1,6 +1,6 @@ /* 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/. */ + * 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 diff --git a/Client/Frontend/Login Management/BreachAlertsManager.swift b/Client/Frontend/Login Management/BreachAlertsManager.swift index 5cb98f493e52..6abfb1514d6b 100644 --- a/Client/Frontend/Login Management/BreachAlertsManager.swift +++ b/Client/Frontend/Login Management/BreachAlertsManager.swift @@ -1,6 +1,6 @@ /* 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/. */ + * 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 // or whichever module has the LoginsRecord class @@ -56,7 +56,7 @@ final public class BreachAlertsManager { /// Compares a list of logins to a list of breaches and returns breached logins. /// - Parameters: /// - logins: a list of logins to compare breaches to - func compareToBreaches(_ logins: [LoginRecord]) -> Maybe<[LoginRecord]> { + func findUserBreaches(_ logins: [LoginRecord]) -> Maybe<[LoginRecord]> { var result: [LoginRecord] = [] if self.breaches.count <= 0 { diff --git a/Client/Frontend/Login Management/LoginDataSource.swift b/Client/Frontend/Login Management/LoginDataSource.swift new file mode 100644 index 000000000000..30fbc5220d1e --- /dev/null +++ b/Client/Frontend/Login Management/LoginDataSource.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 Foundation +import Shared + +/// Data source for handling LoginData objects from a Cursor +class LoginDataSource: NSObject, UITableViewDataSource { + // in case there are no items to run cellForRowAt on, use an empty state view + fileprivate let emptyStateView = NoLoginsView() + fileprivate var viewModel: LoginListViewModel + + let boolSettings: (BoolSetting, BoolSetting) + + init(viewModel: LoginListViewModel) { + self.viewModel = viewModel + boolSettings = ( + BoolSetting(prefs: viewModel.profile.prefs, prefKey: PrefsKeys.LoginsSaveEnabled, defaultValue: true, attributedTitleText: NSAttributedString(string: Strings.SettingToSaveLogins)), + BoolSetting(prefs: viewModel.profile.prefs, prefKey: PrefsKeys.LoginsShowShortcutMenuItem, defaultValue: true, attributedTitleText: NSAttributedString(string: Strings.SettingToShowLoginsInAppMenu))) + super.init() + } + + @objc func numberOfSections(in tableView: UITableView) -> Int { + if viewModel.loginRecordSections.isEmpty { + tableView.backgroundView = emptyStateView + return 1 + } + + tableView.backgroundView = nil + // Add one section for the settings section. + return viewModel.loginRecordSections.count + 1 + } + + @objc func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + if section == LoginsSettingsSection { + return 2 + } + return viewModel.loginsForSection(section)?.count ?? 0 + } + + @objc func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = ThemedTableViewCell(style: .subtitle, reuseIdentifier: CellReuseIdentifier) + + if indexPath.section == LoginsSettingsSection { + let hideSettings = viewModel.searchController?.isActive ?? false || tableView.isEditing + let setting = indexPath.row == 0 ? boolSettings.0 : boolSettings.1 + setting.onConfigureCell(cell) + if hideSettings { + cell.isHidden = true + } + + // Fade in the cell while dismissing the search or the cell showing suddenly looks janky + if viewModel.isDuringSearchControllerDismiss { + cell.isHidden = false + cell.contentView.alpha = 0 + cell.accessoryView?.alpha = 0 + UIView.animate(withDuration: 0.6) { + cell.contentView.alpha = 1 + cell.accessoryView?.alpha = 1 + } + } + } else { + guard let login = viewModel.loginAtIndexPath(indexPath) else { return cell } + cell.textLabel?.text = login.hostname + cell.detailTextColor = UIColor.theme.tableView.rowDetailText + cell.detailTextLabel?.text = login.username + cell.accessoryType = .disclosureIndicator + } + // Need to override the default background multi-select color to support theming + cell.multipleSelectionBackgroundView = UIView() + cell.applyTheme() + return cell + } +} diff --git a/Client/Frontend/Login Management/LoginListDataSourceHelper.swift b/Client/Frontend/Login Management/LoginListDataSourceHelper.swift new file mode 100644 index 000000000000..4061c097bc64 --- /dev/null +++ b/Client/Frontend/Login Management/LoginListDataSourceHelper.swift @@ -0,0 +1,78 @@ +/* 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 Storage +import Shared + +// MARK: - Data Source +class LoginListDataSourceHelper { + var domainLookup = [GUID: (baseDomain: String?, host: String?, hostname: String)]() + + // Small helper method for using the precomputed base domain to determine the title/section of the + // given login. + func titleForLogin(_ login: LoginRecord) -> Character { + // Fallback to hostname if we can't extract a base domain. + let titleString = domainLookup[login.id]?.baseDomain?.uppercased() ?? login.hostname + return titleString.first ?? Character("") + } + + // Rules for sorting login URLS: + // 1. Compare base domains + // 2. If bases are equal, compare hosts + // 3. If login URL was invalid, revert to full hostname + func sortByDomain(_ loginA: LoginRecord, loginB: LoginRecord) -> Bool { + guard let domainsA = domainLookup[loginA.id], + let domainsB = domainLookup[loginB.id] else { + return false + } + + guard let baseDomainA = domainsA.baseDomain, + let baseDomainB = domainsB.baseDomain, + let hostA = domainsA.host, + let hostB = domainsB.host else { + return domainsA.hostname < domainsB.hostname + } + + if baseDomainA == baseDomainB { + return hostA < hostB + } else { + return baseDomainA < baseDomainB + } + } + + func computeSectionsFromLogins(_ logins: [LoginRecord]) -> Deferred> { + guard logins.count > 0 else { + return deferMaybe( ([Character](), [Character: [LoginRecord]]()) ) + } + + var sections = [Character: [LoginRecord]]() + var titleSet = Set() + + return deferDispatchAsync(DispatchQueue.global(qos: DispatchQoS.userInteractive.qosClass)) { + // Precompute the baseDomain, host, and hostname values for sorting later on. At the moment + // baseDomain() is a costly call because of the ETLD lookup tables. + logins.forEach { login in + self.domainLookup[login.id] = ( + login.hostname.asURL?.baseDomain, + login.hostname.asURL?.host, + login.hostname + ) + } + + // 1. Temporarily insert titles into a Set to get duplicate removal for 'free'. + logins.forEach { titleSet.insert(self.titleForLogin($0)) } + + // 2. Setup an empty list for each title found. + titleSet.forEach { sections[$0] = [LoginRecord]() } + + // 3. Go through our logins and put them in the right section. + logins.forEach { sections[self.titleForLogin($0)]?.append($0) } + + // 4. Go through each section and sort. + sections.forEach { sections[$0] = $1.sorted(by: self.sortByDomain) } + + return deferMaybe( (Array(titleSet).sorted(), sections) ) + } + } +} diff --git a/Client/Frontend/Login Management/LoginListSelectionHelper.swift b/Client/Frontend/Login Management/LoginListSelectionHelper.swift new file mode 100644 index 000000000000..d8f5b4d99e46 --- /dev/null +++ b/Client/Frontend/Login Management/LoginListSelectionHelper.swift @@ -0,0 +1,46 @@ +/* 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 + +/// Helper that keeps track of selected indexes for LoginListViewController +public class LoginListSelectionHelper { + private unowned let tableView: UITableView + private(set) var selectedIndexPaths = [IndexPath]() + + var selectedCount: Int { + return selectedIndexPaths.count + } + + init(tableView: UITableView) { + self.tableView = tableView + } + + func selectIndexPath(_ indexPath: IndexPath) { + selectedIndexPaths.append(indexPath) + } + + func indexPathIsSelected(_ indexPath: IndexPath) -> Bool { + return selectedIndexPaths.contains(indexPath) { path1, path2 in + return path1.row == path2.row && path1.section == path2.section + } + } + + func deselectIndexPath(_ indexPath: IndexPath) { + guard let foundSelectedPath = (selectedIndexPaths.filter { $0.row == indexPath.row && $0.section == indexPath.section }).first, + let indexToRemove = selectedIndexPaths.firstIndex(of: foundSelectedPath) else { + return + } + + selectedIndexPaths.remove(at: indexToRemove) + } + + func deselectAll() { + selectedIndexPaths.removeAll() + } + + func selectIndexPaths(_ indexPaths: [IndexPath]) { + selectedIndexPaths += indexPaths + } +} diff --git a/Client/Frontend/Login Management/LoginListViewController.swift b/Client/Frontend/Login Management/LoginListViewController.swift index f5526323a262..de8d29fe258e 100644 --- a/Client/Frontend/Login Management/LoginListViewController.swift +++ b/Client/Frontend/Login Management/LoginListViewController.swift @@ -8,14 +8,6 @@ import Storage import Shared import SwiftKeychainWrapper -private struct LoginListUX { - static let RowHeight: CGFloat = 58 - static let SearchHeight: CGFloat = 58 - static let selectionButtonFont = UIFont.systemFont(ofSize: 16) - static let NoResultsFont = UIFont.systemFont(ofSize: 16) - static let NoResultsTextColor = UIColor.Photon.Grey40 -} - private extension UITableView { var allLoginIndexPaths: [IndexPath] { return ((LoginsSettingsSection + 1)..>? fileprivate let loadingView = SettingsLoadingView() fileprivate var deleteAlert: UIAlertController? fileprivate var selectionButtonHeightConstraint: Constraint? @@ -61,7 +48,7 @@ class LoginListViewController: SensitiveViewController { fileprivate lazy var selectionButton: UIButton = { let button = UIButton() - button.titleLabel?.font = LoginListUX.selectionButtonFont + button.titleLabel?.font = LoginListViewModel.LoginListUX.selectionButtonFont button.addTarget(self, action: #selector(tappedSelectionButton), for: .touchUpInside) return button }() @@ -109,7 +96,8 @@ class LoginListViewController: SensitiveViewController { } private init(profile: Profile) { - self.profile = profile + self.viewModel = LoginListViewModel(profile: profile, searchController: searchController) + self.loginDataSource = LoginDataSource(viewModel: self.viewModel) super.init(nibName: nil, bundle: nil) } @@ -171,6 +159,7 @@ class LoginListViewController: SensitiveViewController { applyTheme() KeyboardHelper.defaultHelper.addDelegate(self) + viewModel.delegate = self } override func viewWillAppear(_ animated: Bool) { @@ -238,22 +227,12 @@ class LoginListViewController: SensitiveViewController { } fileprivate func toggleSelectionTitle() { - if loginSelectionController.selectedCount == loginDataSource.count { + if loginSelectionController.selectedCount == viewModel.count { selectionButton.setTitle(deselectAllTitle, for: []) } else { selectionButton.setTitle(selectAllTitle, for: []) } } - - // Wrap the SQLiteLogins method to allow us to cancel it from our end. - fileprivate func queryLogins(_ query: String) -> Deferred> { - let deferred = Deferred>() - profile.logins.searchLoginsWithQuery(query) >>== { logins in - deferred.fillIfUnfilled(Maybe(success: logins.asArray())) - succeed() - } - return deferred - } } extension LoginListViewController: UISearchResultsUpdating { @@ -263,15 +242,13 @@ extension LoginListViewController: UISearchResultsUpdating { } } -fileprivate var isDuringSearchControllerDismiss = false - extension LoginListViewController: UISearchControllerDelegate { func willDismissSearchController(_ searchController: UISearchController) { - isDuringSearchControllerDismiss = true + viewModel.setIsDuringSearchControllerDismiss(to: true) } func didDismissSearchController(_ searchController: UISearchController) { - isDuringSearchControllerDismiss = false + viewModel.setIsDuringSearchControllerDismiss(to: false) } } @@ -289,11 +266,7 @@ private extension LoginListViewController { func loadLogins(_ query: String? = nil) { loadingView.isHidden = false - - // Fill in an in-flight query and re-query - activeLoginQuery?.fillIfUnfilled(Maybe(success: [])) - activeLoginQuery = queryLogins(query ?? "") - activeLoginQuery! >>== loginDataSource.setLogins + viewModel.loadLogins(query, loginDataSource: self.loginDataSource) } @objc func beginEditing() { @@ -320,14 +293,14 @@ private extension LoginListViewController { } @objc func tappedDelete() { - profile.logins.hasSyncedLogins().uponQueue(.main) { yes in + viewModel.profile.logins.hasSyncedLogins().uponQueue(.main) { yes in self.deleteAlert = UIAlertController.deleteLoginAlertWithDeleteCallback({ [unowned self] _ in // Delete here let guidsToDelete = self.loginSelectionController.selectedIndexPaths.compactMap { indexPath in - self.loginDataSource.loginAtIndexPath(indexPath)?.id + self.viewModel.loginAtIndexPath(indexPath)?.id } - self.profile.logins.delete(ids: guidsToDelete).uponQueue(.main) { _ in + self.viewModel.profile.logins.delete(ids: guidsToDelete).uponQueue(.main) { _ in self.cancelSelection() self.loadLogins() } @@ -339,7 +312,7 @@ private extension LoginListViewController { @objc func tappedSelectionButton() { // If we haven't selected everything yet, select all - if loginSelectionController.selectedCount < loginDataSource.count { + if loginSelectionController.selectedCount < viewModel.count { // Find all unselected indexPaths let unselectedPaths = tableView.allLoginIndexPaths.filter { indexPath in return !loginSelectionController.indexPathIsSelected(indexPath) @@ -363,23 +336,6 @@ private extension LoginListViewController { } } -// MARK: - LoginDataSourceObserver -extension LoginListViewController: LoginDataSourceObserver { - func loginSectionsDidUpdate() { - loadingView.isHidden = true - tableView.reloadData() - activeLoginQuery = nil - navigationItem.rightBarButtonItem?.isEnabled = loginDataSource.count > 0 - restoreSelectedRows() - } - - func restoreSelectedRows() { - for path in self.loginSelectionController.selectedIndexPaths { - tableView.selectRow(at: path, animated: false, scrollPosition: .none) - } - } -} - // MARK: - UITableViewDelegate extension LoginListViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { @@ -407,7 +363,7 @@ extension LoginListViewController: UITableViewDelegate { if indexPath.section == LoginsSettingsSection, searchController.isActive || tableView.isEditing { return 0 } - return indexPath.section == LoginsSettingsSection ? 44 : LoginListUX.RowHeight + return indexPath.section == LoginsSettingsSection ? 44 : LoginListViewModel.LoginListUX.RowHeight } func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { @@ -419,9 +375,9 @@ extension LoginListViewController: UITableViewDelegate { loginSelectionController.selectIndexPath(indexPath) toggleSelectionTitle() toggleDeleteBarButton() - } else if let login = loginDataSource.loginAtIndexPath(indexPath) { + } else if let login = viewModel.loginAtIndexPath(indexPath) { tableView.deselectRow(at: indexPath, animated: true) - let detailViewController = LoginDetailViewController(profile: profile, login: login) + let detailViewController = LoginDetailViewController(profile: viewModel.profile, login: login) detailViewController.settingsDelegate = settingsDelegate navigationController?.pushViewController(detailViewController, animated: true) } @@ -474,280 +430,19 @@ extension LoginListViewController: SearchInputViewDelegate { } } -/// Controller that keeps track of selected indexes -fileprivate class ListSelectionController: NSObject { - private unowned let tableView: UITableView - private(set) var selectedIndexPaths = [IndexPath]() - - var selectedCount: Int { - return selectedIndexPaths.count - } - - init(tableView: UITableView) { - self.tableView = tableView - super.init() - } - - func selectIndexPath(_ indexPath: IndexPath) { - selectedIndexPaths.append(indexPath) - } - - func indexPathIsSelected(_ indexPath: IndexPath) -> Bool { - return selectedIndexPaths.contains(indexPath) { path1, path2 in - return path1.row == path2.row && path1.section == path2.section - } - } - - func deselectIndexPath(_ indexPath: IndexPath) { - guard let foundSelectedPath = (selectedIndexPaths.filter { $0.row == indexPath.row && $0.section == indexPath.section }).first, - let indexToRemove = selectedIndexPaths.firstIndex(of: foundSelectedPath) else { - return - } - - selectedIndexPaths.remove(at: indexToRemove) - } - - func deselectAll() { - selectedIndexPaths.removeAll() - } - - func selectIndexPaths(_ indexPaths: [IndexPath]) { - selectedIndexPaths += indexPaths - } -} - -protocol LoginDataSourceObserver: AnyObject { - func loginSectionsDidUpdate() -} - -/// Data source for handling LoginData objects from a Cursor -class LoginDataSource: NSObject, UITableViewDataSource { - var count = 0 - weak var dataObserver: LoginDataSourceObserver? - weak var searchController: UISearchController? - fileprivate let emptyStateView = NoLoginsView() - fileprivate var titles = [Character]() - - let boolSettings: (BoolSetting, BoolSetting) - - init(profile: Profile, searchController: UISearchController) { - self.searchController = searchController - boolSettings = ( - BoolSetting(prefs: profile.prefs, prefKey: PrefsKeys.LoginsSaveEnabled, defaultValue: true, attributedTitleText: NSAttributedString(string: Strings.SettingToSaveLogins)), - BoolSetting(prefs: profile.prefs, prefKey: PrefsKeys.LoginsShowShortcutMenuItem, defaultValue: true, attributedTitleText: NSAttributedString(string: Strings.SettingToShowLoginsInAppMenu))) - super.init() - } - - fileprivate var loginRecordSections = [Character: [LoginRecord]]() { - didSet { - assert(Thread.isMainThread) - self.dataObserver?.loginSectionsDidUpdate() - } - } - - fileprivate func loginsForSection(_ section: Int) -> [LoginRecord]? { - guard section > 0 else { - assertionFailure() - return nil - } - let titleForSectionIndex = titles[section - 1] - return loginRecordSections[titleForSectionIndex] - } - - func loginAtIndexPath(_ indexPath: IndexPath) -> LoginRecord? { - guard indexPath.section > 0 else { - assertionFailure() - return nil - } - let titleForSectionIndex = titles[indexPath.section - 1] - guard let section = loginRecordSections[titleForSectionIndex] else { - assertionFailure() - return nil - } - - assert(indexPath.row <= section.count) - - return section[indexPath.row] - } - - @objc func numberOfSections(in tableView: UITableView) -> Int { - if loginRecordSections.isEmpty { - tableView.backgroundView = emptyStateView - return 1 - } - - tableView.backgroundView = nil - // Add one section for the settings section. - return loginRecordSections.count + 1 - } - - @objc func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - if section == LoginsSettingsSection { - return 2 - } - return loginsForSection(section)?.count ?? 0 - } - - @objc func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = ThemedTableViewCell(style: .subtitle, reuseIdentifier: CellReuseIdentifier) - - if indexPath.section == LoginsSettingsSection { - let hideSettings = searchController?.isActive ?? false || tableView.isEditing - let setting = indexPath.row == 0 ? boolSettings.0 : boolSettings.1 - setting.onConfigureCell(cell) - if hideSettings { - cell.isHidden = true - } - - // Fade in the cell while dismissing the search or the cell showing suddenly looks janky - if isDuringSearchControllerDismiss { - cell.isHidden = false - cell.contentView.alpha = 0 - cell.accessoryView?.alpha = 0 - UIView.animate(withDuration: 0.6) { - cell.contentView.alpha = 1 - cell.accessoryView?.alpha = 1 - } - } - } else { - guard let login = loginAtIndexPath(indexPath) else { return cell } - cell.textLabel?.text = login.hostname - cell.detailTextColor = UIColor.theme.tableView.rowDetailText - cell.detailTextLabel?.text = login.username - cell.accessoryType = .disclosureIndicator - } - - // Need to override the default background multi-select color to support theming - cell.multipleSelectionBackgroundView = UIView() - cell.applyTheme() - return cell - } - - func setLogins(_ logins: [LoginRecord]) { - // NB: Make sure we call the callback on the main thread so it can be synced up with a reloadData to - // prevent race conditions between data/UI indexing. - return computeSectionsFromLogins(logins).uponQueue(.main) { result in - guard let (titles, sections) = result.successValue else { - self.count = 0 - self.titles = [] - self.loginRecordSections = [:] - return - } - - self.count = logins.count - self.titles = titles - self.loginRecordSections = sections - - // Disable the search controller if there are no logins saved - if !(self.searchController?.isActive ?? true) { - self.searchController?.searchBar.isUserInteractionEnabled = !logins.isEmpty - self.searchController?.searchBar.alpha = logins.isEmpty ? 0.5 : 1.0 - } - } - } - - fileprivate func computeSectionsFromLogins(_ logins: [LoginRecord]) -> Deferred> { - guard logins.count > 0 else { - return deferMaybe( ([Character](), [Character: [LoginRecord]]()) ) - } - - var domainLookup = [GUID: (baseDomain: String?, host: String?, hostname: String)]() - var sections = [Character: [LoginRecord]]() - var titleSet = Set() - - // Small helper method for using the precomputed base domain to determine the title/section of the - // given login. - func titleForLogin(_ login: LoginRecord) -> Character { - // Fallback to hostname if we can't extract a base domain. - let titleString = domainLookup[login.id]?.baseDomain?.uppercased() ?? login.hostname - return titleString.first ?? Character("") - } - - // Rules for sorting login URLS: - // 1. Compare base domains - // 2. If bases are equal, compare hosts - // 3. If login URL was invalid, revert to full hostname - func sortByDomain(_ loginA: LoginRecord, loginB: LoginRecord) -> Bool { - guard let domainsA = domainLookup[loginA.id], - let domainsB = domainLookup[loginB.id] else { - return false - } - - guard let baseDomainA = domainsA.baseDomain, - let baseDomainB = domainsB.baseDomain, - let hostA = domainsA.host, - let hostB = domainsB.host else { - return domainsA.hostname < domainsB.hostname - } - - if baseDomainA == baseDomainB { - return hostA < hostB - } else { - return baseDomainA < baseDomainB - } - } - - return deferDispatchAsync(DispatchQueue.global(qos: DispatchQoS.userInteractive.qosClass)) { - // Precompute the baseDomain, host, and hostname values for sorting later on. At the moment - // baseDomain() is a costly call because of the ETLD lookup tables. - logins.forEach { login in - domainLookup[login.id] = ( - login.hostname.asURL?.baseDomain, - login.hostname.asURL?.host, - login.hostname - ) - } - - // 1. Temporarily insert titles into a Set to get duplicate removal for 'free'. - logins.forEach { titleSet.insert(titleForLogin($0)) } - - // 2. Setup an empty list for each title found. - titleSet.forEach { sections[$0] = [LoginRecord]() } - - // 3. Go through our logins and put them in the right section. - logins.forEach { sections[titleForLogin($0)]?.append($0) } - - // 4. Go through each section and sort. - sections.forEach { sections[$0] = $1.sorted(by: sortByDomain) } - - return deferMaybe( (Array(titleSet).sorted(), sections) ) - } - } -} - -// Empty state view when there is no logins to display. -fileprivate class NoLoginsView: UIView { - - // We use the search bar height to maintain visual balance with the whitespace on this screen. The - // title label is centered visually using the empty view + search bar height as the size to center with. - var searchBarHeight: CGFloat = 0 { - didSet { - setNeedsUpdateConstraints() - } - } - - lazy var titleLabel: UILabel = { - let label = UILabel() - label.font = LoginListUX.NoResultsFont - label.textColor = LoginListUX.NoResultsTextColor - label.text = NSLocalizedString("No logins found", tableName: "LoginManager", comment: "Label displayed when no logins are found after searching.") - return label - }() +// MARK: - LoginViewModelDelegate +extension LoginListViewController: LoginViewModelDelegate { - override init(frame: CGRect) { - super.init(frame: frame) - addSubview(titleLabel) + func loginSectionsDidUpdate() { + loadingView.isHidden = true + tableView.reloadData() + navigationItem.rightBarButtonItem?.isEnabled = viewModel.count > 0 + restoreSelectedRows() } - fileprivate override func updateConstraints() { - super.updateConstraints() - titleLabel.snp.remakeConstraints { make in - make.centerX.equalTo(self) - make.centerY.equalTo(self).offset(-(searchBarHeight / 2)) + func restoreSelectedRows() { + for path in self.loginSelectionController.selectedIndexPaths { + tableView.selectRow(at: path, animated: false, scrollPosition: .none) } } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } } diff --git a/Client/Frontend/Login Management/LoginListViewModel.swift b/Client/Frontend/Login Management/LoginListViewModel.swift new file mode 100644 index 000000000000..7ac7f3ef8f19 --- /dev/null +++ b/Client/Frontend/Login Management/LoginListViewModel.swift @@ -0,0 +1,117 @@ +/* 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 + +// MARK: - Main View Model +// Login List View Model +final class LoginListViewModel { + + let profile: Profile + private(set) var isDuringSearchControllerDismiss = false + private(set) var count = 0 + weak var searchController: UISearchController? + weak var delegate: LoginViewModelDelegate? + fileprivate var activeLoginQuery: Deferred>? + private(set) var titles = [Character]() + private(set) var loginRecordSections = [Character: [LoginRecord]]() { + didSet { + assert(Thread.isMainThread) + delegate?.loginSectionsDidUpdate() + } + } + fileprivate let helper = LoginListDataSourceHelper() + + init(profile: Profile, searchController: UISearchController) { + self.profile = profile + self.searchController = searchController + } + + func loadLogins(_ query: String? = nil, loginDataSource: LoginDataSource) { + // Fill in an in-flight query and re-query + activeLoginQuery?.fillIfUnfilled(Maybe(success: [])) + activeLoginQuery = queryLogins(query ?? "") + activeLoginQuery! >>== self.setLogins + } + + /// Searches SQLite database for logins that match query. + /// Wraps the SQLiteLogins method to allow us to cancel it from our end. + func queryLogins(_ query: String) -> Deferred> { + let deferred = Deferred>() + profile.logins.searchLoginsWithQuery(query) >>== { logins in + deferred.fillIfUnfilled(Maybe(success: logins.asArray())) + succeed() + } + return deferred + } + + func setIsDuringSearchControllerDismiss(to: Bool) { + self.isDuringSearchControllerDismiss = to + } + + // MARK: - Data Source Methods + func loginAtIndexPath(_ indexPath: IndexPath) -> LoginRecord? { + guard indexPath.section > 0 else { + assertionFailure() + return nil + } + let titleForSectionIndex = titles[indexPath.section - 1] + guard let section = loginRecordSections[titleForSectionIndex] else { + assertionFailure() + return nil + } + + assert(indexPath.row <= section.count) + + return section[indexPath.row] + } + + func loginsForSection(_ section: Int) -> [LoginRecord]? { + guard section > 0 else { + assertionFailure() + return nil + } + let titleForSectionIndex = titles[section - 1] + return loginRecordSections[titleForSectionIndex] + } + + func setLogins(_ logins: [LoginRecord]) { + // NB: Make sure we call the callback on the main thread so it can be synced up with a reloadData to + // prevent race conditions between data/UI indexing. + return self.helper.computeSectionsFromLogins(logins).uponQueue(.main) { result in + guard let (titles, sections) = result.successValue else { + self.count = 0 + self.titles = [] + self.loginRecordSections = [:] + return + } + + self.count = logins.count + self.titles = titles + self.loginRecordSections = sections + + // Disable the search controller if there are no logins saved + if !(self.searchController?.isActive ?? true) { + self.searchController?.searchBar.isUserInteractionEnabled = !logins.isEmpty + self.searchController?.searchBar.alpha = logins.isEmpty ? 0.5 : 1.0 + } + } + } + + // MARK: - UX Constants + struct LoginListUX { + static let RowHeight: CGFloat = 58 + static let SearchHeight: CGFloat = 58 + static let selectionButtonFont = UIFont.systemFont(ofSize: 16) + static let NoResultsFont = UIFont.systemFont(ofSize: 16) + static let NoResultsTextColor = UIColor.Photon.Grey40 + } +} + +// MARK: - LoginDataSourceViewModelDelegate +protocol LoginViewModelDelegate: AnyObject { + func loginSectionsDidUpdate() +} diff --git a/Client/Frontend/Login Management/NoLoginsView.swift b/Client/Frontend/Login Management/NoLoginsView.swift new file mode 100644 index 000000000000..6d0202c496f6 --- /dev/null +++ b/Client/Frontend/Login Management/NoLoginsView.swift @@ -0,0 +1,42 @@ +/* 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 + +/// Empty state view when there is no logins to display. +class NoLoginsView: UIView { + + // We use the search bar height to maintain visual balance with the whitespace on this screen. The + // title label is centered visually using the empty view + search bar height as the size to center with. + var searchBarHeight: CGFloat = 0 { + didSet { + setNeedsUpdateConstraints() + } + } + + lazy var titleLabel: UILabel = { + let label = UILabel() + label.font = LoginListViewModel.LoginListUX.NoResultsFont + label.textColor = LoginListViewModel.LoginListUX.NoResultsTextColor + label.text = NSLocalizedString("No logins found", tableName: "LoginManager", comment: "Label displayed when no logins are found after searching.") + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + addSubview(titleLabel) + } + + internal override func updateConstraints() { + super.updateConstraints() + titleLabel.snp.remakeConstraints { make in + make.centerX.equalTo(self) + make.centerY.equalTo(self).offset(-(searchBarHeight / 2)) + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/ClientTests/BreachAlertsTests.swift b/ClientTests/BreachAlertsTests.swift index 78e63f9bd0b3..a96adcd05d4a 100644 --- a/ClientTests/BreachAlertsTests.swift +++ b/ClientTests/BreachAlertsTests.swift @@ -48,27 +48,27 @@ class BreachAlertsTests: XCTestCase { } /// Test for testing compareBreaches func testCompareBreaches() { - let unloadedBreachesOpt = self.breachAlertsManager?.compareToBreaches(breachedLogin) + let unloadedBreachesOpt = self.breachAlertsManager?.findUserBreaches(breachedLogin) XCTAssertNotNil(unloadedBreachesOpt) if let unloadedBreaches = unloadedBreachesOpt { XCTAssertTrue(unloadedBreaches.isFailure) } breachAlertsManager?.loadBreaches { maybeBreachList in - let emptyLoginsOpt = self.breachAlertsManager?.compareToBreaches([]) + let emptyLoginsOpt = self.breachAlertsManager?.findUserBreaches([]) XCTAssertNotNil(emptyLoginsOpt) if let emptyLogins = emptyLoginsOpt { XCTAssertTrue(emptyLogins.isFailure) } - let noBreachesOpt = self.breachAlertsManager?.compareToBreaches(self.unbreachedLogin) + let noBreachesOpt = self.breachAlertsManager?.findUserBreaches(self.unbreachedLogin) XCTAssertNotNil(noBreachesOpt) if let noBreaches = noBreachesOpt { XCTAssertTrue(noBreaches.isSuccess) XCTAssertEqual(noBreaches.successValue?.count, 0) } - let breachedOpt = self.breachAlertsManager?.compareToBreaches(self.breachedLogin) + let breachedOpt = self.breachAlertsManager?.findUserBreaches(self.breachedLogin) XCTAssertNotNil(breachedOpt) if let breached = breachedOpt { XCTAssertTrue(breached.isSuccess) From 43b9c69945f2f74fe52383215f02f782bc756a47 Mon Sep 17 00:00:00 2001 From: Vanna Phong Date: Fri, 10 Jul 2020 18:01:47 -0700 Subject: [PATCH 07/14] Test LoginsList-related refactored classes (#6897) * LoginsList test stubs * fix test db deletion * query test * more view model tests * test on properties set by setLogins * open logins db * LoginListSelectionHelper tests * VM helper tests * headers * Delete LoginListDataSourceHelper.swift * queue-ify loadLogins * queue-ify tests * computeSectionsFromLogin + revert queues * remove tests requiring loadLogins to be called + cleanup * review changes * cleanup after renaming --- Client.xcodeproj/project.pbxproj | 18 +++- .../LoginListDataSourceHelper.swift | 29 ++++--- .../Login Management/LoginListViewModel.swift | 16 ++-- ClientTests/BreachAlertsTests.swift | 5 +- .../LoginsListDataSourceHelperTests.swift | 82 +++++++++++++++++++ .../LoginsListSelectionHelperTests.swift | 71 ++++++++++++++++ ClientTests/LoginsListViewModelTests.swift | 72 ++++++++++++++++ ClientTests/MockProfile.swift | 2 + 8 files changed, 274 insertions(+), 21 deletions(-) create mode 100644 ClientTests/LoginsListDataSourceHelperTests.swift create mode 100644 ClientTests/LoginsListSelectionHelperTests.swift create mode 100644 ClientTests/LoginsListViewModelTests.swift diff --git a/Client.xcodeproj/project.pbxproj b/Client.xcodeproj/project.pbxproj index 20caab7e25fc..1bf98633cb1a 100644 --- a/Client.xcodeproj/project.pbxproj +++ b/Client.xcodeproj/project.pbxproj @@ -380,6 +380,9 @@ C8FB0C75232151BA00031088 /* AppMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8FB0C74232151BA00031088 /* AppMenu.swift */; }; C8FB0C782321523D00031088 /* PageActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8FB0C772321523D00031088 /* PageActionMenu.swift */; }; CA03B26A247F1D9E00382B62 /* BreachAlertsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA03B269247F1D9E00382B62 /* BreachAlertsClient.swift */; }; + CA24B52224ABD7D40093848C /* LoginsListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA24B52024ABD7D40093848C /* LoginsListViewModelTests.swift */; }; + CA24B53924ABFE250093848C /* LoginsListSelectionHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA24B53824ABFE250093848C /* LoginsListSelectionHelperTests.swift */; }; + CA24B53B24ABFE5D0093848C /* LoginsListDataSourceHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA24B53A24ABFE5D0093848C /* LoginsListDataSourceHelperTests.swift */; }; CA520E7A24913C1B00CCAB48 /* LoginListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA520E7924913C1B00CCAB48 /* LoginListViewModel.swift */; }; CA77ABFD24773C92005079F9 /* BreachAlertsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA77ABF424772D98005079F9 /* BreachAlertsManager.swift */; }; CA7BD568248189E800A0A61B /* BreachAlertsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA7BD564248185B500A0A61B /* BreachAlertsTests.swift */; }; @@ -1533,6 +1536,9 @@ C8FB0C74232151BA00031088 /* AppMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMenu.swift; sourceTree = ""; }; C8FB0C772321523D00031088 /* PageActionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageActionMenu.swift; sourceTree = ""; }; CA03B269247F1D9E00382B62 /* BreachAlertsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreachAlertsClient.swift; sourceTree = ""; }; + CA24B52024ABD7D40093848C /* LoginsListViewModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginsListViewModelTests.swift; sourceTree = ""; }; + CA24B53824ABFE250093848C /* LoginsListSelectionHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginsListSelectionHelperTests.swift; sourceTree = ""; }; + CA24B53A24ABFE5D0093848C /* LoginsListDataSourceHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginsListDataSourceHelperTests.swift; sourceTree = ""; }; CA520E7924913C1B00CCAB48 /* LoginListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginListViewModel.swift; sourceTree = ""; }; CA77ABF424772D98005079F9 /* BreachAlertsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreachAlertsManager.swift; sourceTree = ""; }; CA7BD564248185B500A0A61B /* BreachAlertsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreachAlertsTests.swift; sourceTree = ""; }; @@ -3177,14 +3183,14 @@ E63ED8DF1BFD254E0097D08E /* Login Management */ = { isa = PBXGroup; children = ( - E63ED7D71BFCD9990097D08E /* LoginTableViewCell.swift */, E63ED8E01BFD25580097D08E /* LoginListViewController.swift */, + E63ED7D71BFCD9990097D08E /* LoginTableViewCell.swift */, E633E2D91C21EAF8001FFF6C /* LoginDetailViewController.swift */, + CAA3B7E52497DCB60094E3C1 /* LoginDataSource.swift */, CA520E7924913C1B00CCAB48 /* LoginListViewModel.swift */, + CA7FC7D224A6A9B70012F347 /* LoginListDataSourceHelper.swift */, CA90753724929B22005B794D /* NoLoginsView.swift */, - CAA3B7E52497DCB60094E3C1 /* LoginDataSource.swift */, CAC458F0249429C20042561A /* LoginListSelectionHelper.swift */, - CA7FC7D224A6A9B70012F347 /* LoginListDataSourceHelper.swift */, CA77ABF424772D98005079F9 /* BreachAlertsManager.swift */, CA03B269247F1D9E00382B62 /* BreachAlertsClient.swift */, ); @@ -3531,6 +3537,9 @@ 43446CED2412DDB100F5C643 /* UpdateCoverSheet */, 8DCD3BCC1ED5B7FA00446D38 /* FxADeepLinkingTests.swift */, CA7BD564248185B500A0A61B /* BreachAlertsTests.swift */, + CA24B53A24ABFE5D0093848C /* LoginsListDataSourceHelperTests.swift */, + CA24B52024ABD7D40093848C /* LoginsListViewModelTests.swift */, + CA24B53824ABFE250093848C /* LoginsListSelectionHelperTests.swift */, 3B6F40171DC7849C00656CC6 /* FirefoxHomeTests.swift */, F84B21D91A090F8100AAB793 /* ClientTests.swift */, 3B39EDB91E16E18900EF029F /* CustomSearchEnginesTest.swift */, @@ -5409,6 +5418,8 @@ 0BA8964C1A250E6500C1010C /* TestBookmarks.swift in Sources */, D8BA1790206D47830023AC00 /* DeferredTestUtils.swift in Sources */, 2F13E79B1AC0C02700D75081 /* StringExtensionsTests.swift in Sources */, + CA24B52224ABD7D40093848C /* LoginsListViewModelTests.swift in Sources */, + CA24B53924ABFE250093848C /* LoginsListSelectionHelperTests.swift in Sources */, 2FDB10931A9FBEC5006CF312 /* PrefsTests.swift in Sources */, D8EFFA261FF702A8001D3A09 /* NavigationRouterTests.swift in Sources */, 63306D452110BAF000F25400 /* TabManagerStoreTests.swift in Sources */, @@ -5418,6 +5429,7 @@ 28D52E2F1BCDF53900187A1D /* ResetTests.swift in Sources */, E1D8BC7A21FF7A0000B100BD /* TPStatsBlocklistsTests.swift in Sources */, D82ED2641FEB3C420059570B /* DefaultSearchPrefsTests.swift in Sources */, + CA24B53B24ABFE5D0093848C /* LoginsListDataSourceHelperTests.swift in Sources */, 3B61CD591F2A750800D38DE1 /* PocketFeedTests.swift in Sources */, 43446CF02412DDBE00F5C643 /* UpdateCoverSheetViewModelTests.swift in Sources */, 3B6F40181DC7849C00656CC6 /* FirefoxHomeTests.swift in Sources */, diff --git a/Client/Frontend/Login Management/LoginListDataSourceHelper.swift b/Client/Frontend/Login Management/LoginListDataSourceHelper.swift index 4061c097bc64..20616d342a98 100644 --- a/Client/Frontend/Login Management/LoginListDataSourceHelper.swift +++ b/Client/Frontend/Login Management/LoginListDataSourceHelper.swift @@ -5,9 +5,21 @@ import Storage import Shared -// MARK: - Data Source class LoginListDataSourceHelper { - var domainLookup = [GUID: (baseDomain: String?, host: String?, hostname: String)]() + private(set) var domainLookup = [GUID: (baseDomain: String?, host: String?, hostname: String)]() + + // Precompute the baseDomain, host, and hostname values for sorting later on. At the moment + // baseDomain() is a costly call because of the ETLD lookup tables. + func setDomainLookup(_ logins: [LoginRecord]) { + self.domainLookup = [:] + logins.forEach { login in + self.domainLookup[login.id] = ( + login.hostname.asURL?.baseDomain, + login.hostname.asURL?.host, + login.hostname + ) + } + } // Small helper method for using the precomputed base domain to determine the title/section of the // given login. @@ -49,17 +61,12 @@ class LoginListDataSourceHelper { var sections = [Character: [LoginRecord]]() var titleSet = Set() - return deferDispatchAsync(DispatchQueue.global(qos: DispatchQoS.userInteractive.qosClass)) { - // Precompute the baseDomain, host, and hostname values for sorting later on. At the moment - // baseDomain() is a costly call because of the ETLD lookup tables. - logins.forEach { login in - self.domainLookup[login.id] = ( - login.hostname.asURL?.baseDomain, - login.hostname.asURL?.host, - login.hostname - ) + return deferDispatchAsync(DispatchQueue.global(qos: DispatchQoS.userInteractive.qosClass)) { [weak self] in + guard let self = self else { + return deferMaybe( ([Character](), [Character: [LoginRecord]]()) ) } + self.setDomainLookup(logins) // 1. Temporarily insert titles into a Set to get duplicate removal for 'free'. logins.forEach { titleSet.insert(self.titleForLogin($0)) } diff --git a/Client/Frontend/Login Management/LoginListViewModel.swift b/Client/Frontend/Login Management/LoginListViewModel.swift index 7ac7f3ef8f19..baddc80922f8 100644 --- a/Client/Frontend/Login Management/LoginListViewModel.swift +++ b/Client/Frontend/Login Management/LoginListViewModel.swift @@ -10,12 +10,12 @@ import Shared // Login List View Model final class LoginListViewModel { - let profile: Profile + private(set) var profile: Profile private(set) var isDuringSearchControllerDismiss = false private(set) var count = 0 weak var searchController: UISearchController? weak var delegate: LoginViewModelDelegate? - fileprivate var activeLoginQuery: Deferred>? + private(set) var activeLoginQuery: Deferred>? private(set) var titles = [Character]() private(set) var loginRecordSections = [Character: [LoginRecord]]() { didSet { @@ -32,9 +32,9 @@ final class LoginListViewModel { func loadLogins(_ query: String? = nil, loginDataSource: LoginDataSource) { // Fill in an in-flight query and re-query - activeLoginQuery?.fillIfUnfilled(Maybe(success: [])) - activeLoginQuery = queryLogins(query ?? "") - activeLoginQuery! >>== self.setLogins + self.activeLoginQuery?.fillIfUnfilled(Maybe(success: [])) + self.activeLoginQuery = self.queryLogins(query ?? "") + self.activeLoginQuery! >>== self.setLogins } /// Searches SQLite database for logins that match query. @@ -115,3 +115,9 @@ final class LoginListViewModel { protocol LoginViewModelDelegate: AnyObject { func loginSectionsDidUpdate() } + +extension LoginRecord: Equatable { + public static func == (lhs: LoginRecord, rhs: LoginRecord) -> Bool { + return lhs.id == rhs.id + } +} diff --git a/ClientTests/BreachAlertsTests.swift b/ClientTests/BreachAlertsTests.swift index a96adcd05d4a..1c9d05c55c3b 100644 --- a/ClientTests/BreachAlertsTests.swift +++ b/ClientTests/BreachAlertsTests.swift @@ -33,10 +33,11 @@ class BreachAlertsTests: XCTestCase { let breachedLogin = [ LoginRecord(fromJSONDict: ["hostname" : "http://breached.com", "timePasswordChanged": 1]) ] + override func setUp() { self.breachAlertsManager = BreachAlertsManager(MockBreachAlertsClient()) } - /// Test for testing loadBreaches + func testDataRequest() { breachAlertsManager?.loadBreaches { maybeBreaches in XCTAssertTrue(maybeBreaches.isSuccess) @@ -46,7 +47,7 @@ class BreachAlertsTests: XCTestCase { } } } - /// Test for testing compareBreaches + func testCompareBreaches() { let unloadedBreachesOpt = self.breachAlertsManager?.findUserBreaches(breachedLogin) XCTAssertNotNil(unloadedBreachesOpt) diff --git a/ClientTests/LoginsListDataSourceHelperTests.swift b/ClientTests/LoginsListDataSourceHelperTests.swift new file mode 100644 index 000000000000..9da3dff29de0 --- /dev/null +++ b/ClientTests/LoginsListDataSourceHelperTests.swift @@ -0,0 +1,82 @@ +/* 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/. */ + +@testable import Client +import Storage +import Shared +import XCTest + +class LoginListDataSourceHelperTests: XCTestCase { + var helper: LoginListDataSourceHelper! + override func setUp() { + helper = LoginListDataSourceHelper() + } + + func testSetDomainLookup() { + let login = LoginRecord(fromJSONDict: [ + "hostname": "https://example.com/", + "id": "example" + ]) + self.helper.setDomainLookup([login]) + XCTAssertNotNil(self.helper.domainLookup[login.id]) + XCTAssertEqual(self.helper.domainLookup[login.id]?.baseDomain, login.hostname.asURL?.baseDomain) + XCTAssertEqual(self.helper.domainLookup[login.id]?.host, login.hostname.asURL?.host) + XCTAssertEqual(self.helper.domainLookup[login.id]?.hostname, login.hostname) + } + + func testTitleForLogin() { + let login = LoginRecord(fromJSONDict: [ + "hostname": "https://example.com/", + "id": "example" + ]) + self.helper.setDomainLookup([login]) + XCTAssertEqual(self.helper.titleForLogin(login), Character("E")) + } + + func testSortByDomain() { + let apple = LoginRecord(fromJSONDict: [ + "hostname": "https://apple.com/", + "id": "apple" + ]) + let zebra = LoginRecord(fromJSONDict: [ + "hostname": "https://zebra.com/", + "id": "zebra" + ]) + XCTAssertFalse(self.helper.sortByDomain(apple, loginB: zebra)) + + self.helper.setDomainLookup([apple, zebra]) + XCTAssertTrue(self.helper.sortByDomain(apple, loginB: zebra)) + XCTAssertFalse(self.helper.sortByDomain(zebra, loginB: apple)) + } + + func testComputeSectionsFromLogins() { + let apple = LoginRecord(fromJSONDict: [ + "hostname": "https://apple.com/", + "id": "apple" + ]) + let appleMusic = LoginRecord(fromJSONDict: [ + "hostname": "https://apple.com/music", + "id": "appleMusic" + ]) + let zebra = LoginRecord(fromJSONDict: [ + "hostname": "https://zebra.com/", + "id": "zebra" + ]) + + let sortedTitles = [Character("A"), Character("Z")] + var expected = [Character: [LoginRecord]]() + expected[Character("A")] = [apple, appleMusic] + expected[Character("Z")] = [zebra] + + let logins = [apple, appleMusic, zebra] + self.helper.setDomainLookup(logins) + self.helper.computeSectionsFromLogins(logins).upon { (formattedLoginsMaybe) in + XCTAssertTrue(formattedLoginsMaybe.isSuccess) + XCTAssertNotNil(formattedLoginsMaybe.successValue) + let formattedLogins = formattedLoginsMaybe.successValue + XCTAssertEqual(formattedLogins?.0, sortedTitles) + XCTAssertEqual(formattedLogins?.1, expected) + } + } +} diff --git a/ClientTests/LoginsListSelectionHelperTests.swift b/ClientTests/LoginsListSelectionHelperTests.swift new file mode 100644 index 000000000000..4d2fc49f32aa --- /dev/null +++ b/ClientTests/LoginsListSelectionHelperTests.swift @@ -0,0 +1,71 @@ +/* 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/. */ + +@testable import Client +import XCTest + +class LoginsListSelectionHelperTests: XCTestCase { + var selectionHelper: LoginListSelectionHelper! + + override func setUp() { + let tableView = UITableView() + self.selectionHelper = LoginListSelectionHelper(tableView: tableView) + } + + func testSelectIndexPath() { + XCTAssertEqual(selectionHelper.selectedCount, 0) + XCTAssertEqual(selectionHelper.selectedIndexPaths, []) + let selection = IndexPath(row: 1, section: 1) + self.selectionHelper.selectIndexPath(selection) + XCTAssertEqual(selectionHelper.selectedCount, 1) + XCTAssertEqual(selectionHelper.selectedIndexPaths, [selection]) + } + + func testIndexPathIsSelected() { + let selection = IndexPath(row: 1, section: 1) + XCTAssertFalse(self.selectionHelper.indexPathIsSelected(selection)) + self.selectionHelper.selectIndexPath(selection) + XCTAssertTrue(self.selectionHelper.indexPathIsSelected(selection)) + } + + func testDeselectIndexPath() { + let selection = IndexPath(row: 1, section: 1) + XCTAssertEqual(selectionHelper.selectedCount, 0) + XCTAssertFalse(self.selectionHelper.indexPathIsSelected(selection)) + self.selectionHelper.deselectIndexPath(selection) + XCTAssertEqual(selectionHelper.selectedCount, 0) + XCTAssertFalse(self.selectionHelper.indexPathIsSelected(selection)) + self.selectionHelper.selectIndexPath(selection) + XCTAssertEqual(selectionHelper.selectedCount, 1) + XCTAssertTrue(self.selectionHelper.indexPathIsSelected(selection)) + self.selectionHelper.deselectIndexPath(selection) + XCTAssertEqual(selectionHelper.selectedCount, 0) + XCTAssertFalse(self.selectionHelper.indexPathIsSelected(selection)) + } + + func testDeselectAll() { + XCTAssertEqual(selectionHelper.selectedIndexPaths, []) + self.selectionHelper.deselectAll() + XCTAssertEqual(selectionHelper.selectedIndexPaths, []) + let selection1 = IndexPath(row: 1, section: 1) + let selection2 = IndexPath(row: 2, section: 2) + self.selectionHelper.selectIndexPath(selection1) + XCTAssertEqual(selectionHelper.selectedCount, 1) + self.selectionHelper.deselectAll() + XCTAssertEqual(selectionHelper.selectedIndexPaths, []) + self.selectionHelper.selectIndexPath(selection1) + self.selectionHelper.selectIndexPath(selection2) + XCTAssertEqual(selectionHelper.selectedCount, 2) + self.selectionHelper.deselectAll() + XCTAssertEqual(selectionHelper.selectedCount, 0) + XCTAssertEqual(selectionHelper.selectedIndexPaths, []) + } + + func testSelectIndexPaths() { + XCTAssertEqual(self.selectionHelper.selectedIndexPaths, []) + let selection = [IndexPath(row: 1, section: 1), IndexPath(row: 2, section: 2)] + self.selectionHelper.selectIndexPaths(selection) + XCTAssertEqual(self.selectionHelper.selectedIndexPaths, selection) + } +} diff --git a/ClientTests/LoginsListViewModelTests.swift b/ClientTests/LoginsListViewModelTests.swift new file mode 100644 index 000000000000..8b0c830a0d75 --- /dev/null +++ b/ClientTests/LoginsListViewModelTests.swift @@ -0,0 +1,72 @@ +/* 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/. */ + +@testable import Client +import Storage +import Shared +import XCTest + + +class LoginsListViewModelTests: XCTestCase { + var viewModel: LoginListViewModel! + var dataSource: LoginDataSource! + + override func setUp() { + let mockProfile = MockProfile() + let searchController = UISearchController() + self.viewModel = LoginListViewModel(profile: mockProfile, searchController: searchController) + self.dataSource = LoginDataSource(viewModel: self.viewModel) + } + + private func addLogins() { + _ = self.viewModel.profile.logins.wipeLocal() + + for i in (0..<10) { + let login = LoginRecord(fromJSONDict: [ + "hostname": "https://example\(i).com/", + "formSubmitURL": "https://example.com", + "username": "username\(i)", + "password": "password\(i)" + ]) + login.httpRealm = nil + let addResult = self.viewModel.profile.logins.add(login: login) + XCTAssertTrue(addResult.value.isSuccess) + XCTAssertNotNil(addResult.value.successValue) + } + + let logins = self.viewModel.profile.logins.list().value + XCTAssertTrue(logins.isSuccess) + XCTAssertNotNil(logins.successValue) + } + + func testQueryLogins() { + self.addLogins() + + let emptyQueryResult = self.viewModel.queryLogins("") + XCTAssertTrue(emptyQueryResult.value.isSuccess) + XCTAssertEqual(emptyQueryResult.value.successValue?.count, 10) + + let exampleQueryResult = self.viewModel.queryLogins("example") + XCTAssertTrue(exampleQueryResult.value.isSuccess) + XCTAssertEqual(exampleQueryResult.value.successValue?.count, 10) + + let threeQueryResult = self.viewModel.queryLogins("3") + XCTAssertTrue(threeQueryResult.value.isSuccess) + XCTAssertEqual(threeQueryResult.value.successValue?.count, 1) + + let zQueryResult = self.viewModel.queryLogins("yxz") + XCTAssertTrue(zQueryResult.value.isSuccess) + XCTAssertEqual(zQueryResult.value.successValue?.count, 0) + } + + func testIsDuringSearchControllerDismiss() { + XCTAssertFalse(self.viewModel.isDuringSearchControllerDismiss) + + self.viewModel.setIsDuringSearchControllerDismiss(to: true) + XCTAssertTrue(self.viewModel.isDuringSearchControllerDismiss) + + self.viewModel.setIsDuringSearchControllerDismiss(to: false) + XCTAssertFalse(self.viewModel.isDuringSearchControllerDismiss) + } +} diff --git a/ClientTests/MockProfile.swift b/ClientTests/MockProfile.swift index 58d302b5733d..ad15a68304da 100644 --- a/ClientTests/MockProfile.swift +++ b/ClientTests/MockProfile.swift @@ -126,9 +126,11 @@ open class MockProfile: Client.Profile { files = MockFiles() syncManager = MockSyncManager() let loginsDatabasePath = URL(fileURLWithPath: (try! files.getAndEnsureDirectory()), isDirectory: true).appendingPathComponent("\(databasePrefix)_logins.db").path + try? files.remove("\(databasePrefix)_logins.db") let encryptionKey = "AAAAAAAA" let salt = RustLogins.setupPlaintextHeaderAndGetSalt(databasePath: loginsDatabasePath, encryptionKey: encryptionKey) logins = RustLogins(databasePath: loginsDatabasePath, encryptionKey: encryptionKey, salt: salt) + _ = logins.reopenIfClosed() db = BrowserDB(filename: "\(databasePrefix).db", schema: BrowserSchema(), files: files) readingListDB = BrowserDB(filename: "\(databasePrefix)_ReadingList.db", schema: ReadingListSchema(), files: files) let placesDatabasePath = URL(fileURLWithPath: (try! files.getAndEnsureDirectory()), isDirectory: true).appendingPathComponent("\(databasePrefix)_places.db").path From 8264d1f2d850d91612f519dc4a20fb635411d04e Mon Sep 17 00:00:00 2001 From: Vanna Phong Date: Wed, 15 Jul 2020 17:40:33 -0700 Subject: [PATCH 08/14] Incorporate BreachAlertsManager in to LoginsListViewController (#6934) --- .../BreachAlertsManager.swift | 59 +++++++++++++------ .../Login Management/LoginDataSource.swift | 13 +++- .../LoginListViewController.swift | 6 ++ .../Login Management/LoginListViewModel.swift | 28 +++++++-- ClientTests/BreachAlertsTests.swift | 29 +++++++-- ClientTests/LoginsListViewModelTests.swift | 8 +-- 6 files changed, 107 insertions(+), 36 deletions(-) diff --git a/Client/Frontend/Login Management/BreachAlertsManager.swift b/Client/Frontend/Login Management/BreachAlertsManager.swift index 6abfb1514d6b..e049e1c71531 100644 --- a/Client/Frontend/Login Management/BreachAlertsManager.swift +++ b/Client/Frontend/Login Management/BreachAlertsManager.swift @@ -7,7 +7,7 @@ import Storage // or whichever module has the LoginsRecord class import Shared // or whichever module has the Maybe class /// Breach structure decoded from JSON -struct BreachRecord: Codable, Equatable { +struct BreachRecord: Codable, Equatable, Hashable { var name: String var title: String var domain: String @@ -49,6 +49,13 @@ final public class BreachAlertsManager { } self.breaches = decoded + self.breaches.append(BreachRecord( + name: "MockBreach", + title: "A Mock BreachRecord", + domain: "abreached.com", + breachDate: "1970-01-02", + description: "A mock BreachRecord for testing purposes." + )) completion(Maybe(success: self.breaches)) } } @@ -56,6 +63,8 @@ final public class BreachAlertsManager { /// Compares a list of logins to a list of breaches and returns breached logins. /// - Parameters: /// - logins: a list of logins to compare breaches to + /// - Returns: + /// - an array of LoginRecords of breaches in the original list. func findUserBreaches(_ logins: [LoginRecord]) -> Maybe<[LoginRecord]> { var result: [LoginRecord] = [] @@ -65,27 +74,41 @@ final public class BreachAlertsManager { return Maybe(failure: BreachAlertsError(description: "cannot compare to an empty list of logins")) } - // TODO: optimize this loop - for login in logins { - for breach in self.breaches { - // host check - let loginHostURL = URL(string: login.hostname) - if loginHostURL?.baseDomain == breach.domain { - print("compareToBreaches(): breach: \(breach.domain)") - - // date check - let pwLastChanged = Date(timeIntervalSince1970: TimeInterval(login.timePasswordChanged)) - - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd" - if let breachDate = dateFormatter.date(from: breach.breachDate), pwLastChanged < breachDate { - print("compareToBreaches(): ⚠️ password exposed ⚠️: \(breach.breachDate)") - result.append(login) - } + let loginsDictionary = loginsByHostname(logins) + for breach in self.breaches { + guard let potentialBreaches = loginsDictionary[breach.domain] else { + continue + } + for item in potentialBreaches { + let pwLastChanged = TimeInterval(item.timePasswordChanged/1000) + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + guard let breachDate = dateFormatter.date(from: breach.breachDate)?.timeIntervalSince1970, pwLastChanged < breachDate else { + continue } + print("compareToBreaches(): ⚠️ password exposed ⚠️: \(breach.breachDate)") + result.append(item) } } print("compareToBreaches(): fin") return Maybe(success: result) } + + func loginsByHostname(_ logins: [LoginRecord]) -> [String: [LoginRecord]] { + var result = [String: [LoginRecord]]() + for login in logins { + let base = baseDomainForLogin(login) + if !result.keys.contains(base) { + result[base] = [login] + } else { + result[base]?.append(login) + } + } + return result + } + + private func baseDomainForLogin(_ login: LoginRecord) -> String { + guard let result = login.hostname.asURL?.baseDomain else { return login.hostname } + return result + } } diff --git a/Client/Frontend/Login Management/LoginDataSource.swift b/Client/Frontend/Login Management/LoginDataSource.swift index 30fbc5220d1e..1d18e1390939 100644 --- a/Client/Frontend/Login Management/LoginDataSource.swift +++ b/Client/Frontend/Login Management/LoginDataSource.swift @@ -42,6 +42,10 @@ class LoginDataSource: NSObject, UITableViewDataSource { @objc func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = ThemedTableViewCell(style: .subtitle, reuseIdentifier: CellReuseIdentifier) + // Need to override the default background multi-select color to support theming + cell.multipleSelectionBackgroundView = UIView() + cell.applyTheme() + if indexPath.section == LoginsSettingsSection { let hideSettings = viewModel.searchController?.isActive ?? false || tableView.isEditing let setting = indexPath.row == 0 ? boolSettings.0 : boolSettings.1 @@ -66,10 +70,13 @@ class LoginDataSource: NSObject, UITableViewDataSource { cell.detailTextColor = UIColor.theme.tableView.rowDetailText cell.detailTextLabel?.text = login.username cell.accessoryType = .disclosureIndicator + if let breaches = viewModel.userBreaches { + if breaches.contains(login) { + cell.textLabel?.textColor = UIColor.systemRed + viewModel.setBreachIndexPath(indexPath: indexPath) + } + } } - // Need to override the default background multi-select color to support theming - cell.multipleSelectionBackgroundView = UIView() - cell.applyTheme() return cell } } diff --git a/Client/Frontend/Login Management/LoginListViewController.swift b/Client/Frontend/Login Management/LoginListViewController.swift index de8d29fe258e..c7541a3f9b90 100644 --- a/Client/Frontend/Login Management/LoginListViewController.swift +++ b/Client/Frontend/Login Management/LoginListViewController.swift @@ -433,6 +433,12 @@ extension LoginListViewController: SearchInputViewDelegate { // MARK: - LoginViewModelDelegate extension LoginListViewController: LoginViewModelDelegate { + func breachPathDidUpdate() { + DispatchQueue.main.async { + self.tableView.reloadRows(at: self.viewModel.breachIndexPath, with: .right) + } + } + func loginSectionsDidUpdate() { loadingView.isHidden = true tableView.reloadData() diff --git a/Client/Frontend/Login Management/LoginListViewModel.swift b/Client/Frontend/Login Management/LoginListViewModel.swift index baddc80922f8..9f9217b6d829 100644 --- a/Client/Frontend/Login Management/LoginListViewModel.swift +++ b/Client/Frontend/Login Management/LoginListViewModel.swift @@ -24,6 +24,13 @@ final class LoginListViewModel { } } fileprivate let helper = LoginListDataSourceHelper() + private(set) var breachAlertsManager = BreachAlertsManager() + private(set) var userBreaches: [LoginRecord]? + private(set) var breachIndexPath = [IndexPath]() { + didSet { + delegate?.breachPathDidUpdate() + } + } init(profile: Profile, searchController: UISearchController) { self.profile = profile @@ -32,9 +39,13 @@ final class LoginListViewModel { func loadLogins(_ query: String? = nil, loginDataSource: LoginDataSource) { // Fill in an in-flight query and re-query - self.activeLoginQuery?.fillIfUnfilled(Maybe(success: [])) - self.activeLoginQuery = self.queryLogins(query ?? "") - self.activeLoginQuery! >>== self.setLogins + activeLoginQuery?.fillIfUnfilled(Maybe(success: [])) + activeLoginQuery = queryLogins(query ?? "") + breachAlertsManager.loadBreaches { [weak self] _ in + guard let self = self, let logins = self.activeLoginQuery?.value.successValue else { return } + self.userBreaches = self.breachAlertsManager.findUserBreaches(logins).successValue + } + activeLoginQuery! >>== self.setLogins } /// Searches SQLite database for logins that match query. @@ -42,7 +53,9 @@ final class LoginListViewModel { func queryLogins(_ query: String) -> Deferred> { let deferred = Deferred>() profile.logins.searchLoginsWithQuery(query) >>== { logins in - deferred.fillIfUnfilled(Maybe(success: logins.asArray())) + var log = logins.asArray() + log.append(LoginRecord(fromJSONDict: ["hostname" : "http://abreached.com", "timePasswordChanged": 46800000])) + deferred.fillIfUnfilled(Maybe(success: log)) succeed() } return deferred @@ -101,6 +114,10 @@ final class LoginListViewModel { } } + func setBreachIndexPath(indexPath: IndexPath) { + self.breachIndexPath = [indexPath] + } + // MARK: - UX Constants struct LoginListUX { static let RowHeight: CGFloat = 58 @@ -114,10 +131,11 @@ final class LoginListViewModel { // MARK: - LoginDataSourceViewModelDelegate protocol LoginViewModelDelegate: AnyObject { func loginSectionsDidUpdate() + func breachPathDidUpdate() } extension LoginRecord: Equatable { public static func == (lhs: LoginRecord, rhs: LoginRecord) -> Bool { - return lhs.id == rhs.id + return lhs.id == rhs.id && lhs.hostname == rhs.hostname && lhs.credentials == rhs.credentials } } diff --git a/ClientTests/BreachAlertsTests.swift b/ClientTests/BreachAlertsTests.swift index 1c9d05c55c3b..d89509fce146 100644 --- a/ClientTests/BreachAlertsTests.swift +++ b/ClientTests/BreachAlertsTests.swift @@ -14,6 +14,14 @@ let mockRecord = BreachRecord( breachDate: "1970-01-02", description: "A mock BreachRecord for testing purposes." ) +// remove for official release +let amockRecord = BreachRecord( + name: "MockBreach", + title: "A Mock BreachRecord", + domain: "abreached.com", + breachDate: "1970-01-02", + description: "A mock BreachRecord for testing purposes." +) class MockBreachAlertsClient: BreachAlertsClientProtocol { func fetchData(endpoint: BreachAlertsClient.Endpoint, completion: @escaping (Maybe) -> Void) { @@ -26,12 +34,12 @@ class MockBreachAlertsClient: BreachAlertsClientProtocol { } class BreachAlertsTests: XCTestCase { - var breachAlertsManager: BreachAlertsManager? + var breachAlertsManager: BreachAlertsManager! let unbreachedLogin = [ - LoginRecord(fromJSONDict: ["hostname" : "http://unbreached.com", "timePasswordChanged": 1590784648189]) + LoginRecord(fromJSONDict: ["hostname" : "http://unbreached.com", "timePasswordChanged": 1594411049000]) ] let breachedLogin = [ - LoginRecord(fromJSONDict: ["hostname" : "http://breached.com", "timePasswordChanged": 1]) + LoginRecord(fromJSONDict: ["hostname" : "http://breached.com", "timePasswordChanged": 46800000]) ] override func setUp() { @@ -43,7 +51,7 @@ class BreachAlertsTests: XCTestCase { XCTAssertTrue(maybeBreaches.isSuccess) XCTAssertNotNil(maybeBreaches.successValue) if let breaches = maybeBreaches.successValue { - XCTAssertEqual([mockRecord], breaches) + XCTAssertEqual([mockRecord, amockRecord], breaches) } } } @@ -66,15 +74,24 @@ class BreachAlertsTests: XCTestCase { XCTAssertNotNil(noBreachesOpt) if let noBreaches = noBreachesOpt { XCTAssertTrue(noBreaches.isSuccess) - XCTAssertEqual(noBreaches.successValue?.count, 0) + XCTAssertEqual(noBreaches.successValue, Optional([])) } let breachedOpt = self.breachAlertsManager?.findUserBreaches(self.breachedLogin) XCTAssertNotNil(breachedOpt) if let breached = breachedOpt { XCTAssertTrue(breached.isSuccess) - XCTAssertEqual(breached.successValue?.count, 1) + XCTAssertEqual(breached.successValue, self.breachedLogin) } } } + + func testLoginsByHostname() { + let unbreached = ["unbreached.com": self.unbreachedLogin] + var result = breachAlertsManager.loginsByHostname(self.unbreachedLogin) + XCTAssertEqual(result, unbreached) + let breached = ["breached.com": self.breachedLogin] + result = breachAlertsManager.loginsByHostname(self.breachedLogin) + XCTAssertEqual(result, breached) + } } diff --git a/ClientTests/LoginsListViewModelTests.swift b/ClientTests/LoginsListViewModelTests.swift index 8b0c830a0d75..9a60bc84f136 100644 --- a/ClientTests/LoginsListViewModelTests.swift +++ b/ClientTests/LoginsListViewModelTests.swift @@ -45,19 +45,19 @@ class LoginsListViewModelTests: XCTestCase { let emptyQueryResult = self.viewModel.queryLogins("") XCTAssertTrue(emptyQueryResult.value.isSuccess) - XCTAssertEqual(emptyQueryResult.value.successValue?.count, 10) + XCTAssertEqual(emptyQueryResult.value.successValue?.count, 11) let exampleQueryResult = self.viewModel.queryLogins("example") XCTAssertTrue(exampleQueryResult.value.isSuccess) - XCTAssertEqual(exampleQueryResult.value.successValue?.count, 10) + XCTAssertEqual(exampleQueryResult.value.successValue?.count, 11) let threeQueryResult = self.viewModel.queryLogins("3") XCTAssertTrue(threeQueryResult.value.isSuccess) - XCTAssertEqual(threeQueryResult.value.successValue?.count, 1) + XCTAssertEqual(threeQueryResult.value.successValue?.count, 2) let zQueryResult = self.viewModel.queryLogins("yxz") XCTAssertTrue(zQueryResult.value.isSuccess) - XCTAssertEqual(zQueryResult.value.successValue?.count, 0) + XCTAssertEqual(zQueryResult.value.successValue?.count, 1) } func testIsDuringSearchControllerDismiss() { From 8dab047edf93d5b68df24e40cd2bb488c8fc2d5d Mon Sep 17 00:00:00 2001 From: Vanna Phong Date: Tue, 21 Jul 2020 14:28:49 -0700 Subject: [PATCH 09/14] Add breach alert icon to Logins List cells and display if item is breached (#6992) * basic BreachAlerts surfacing * UI update for breaches * record breach IndexPaths * rewrite findUserBreaches * findUserBreaches refinement * reload table after breaches are loaded to update UI * rework cell reload method * review changes * rudimentary asset display * positioning using custom cell * hide icon and show when needed * refinement * positioning, vector size, additional mock data * cleanup * bug cleanup/polish * convert array to set for performance * forEach reloadRows optimization * margins + breach reload optimization Co-authored-by: Garvan Keeley Co-authored-by: Nishant Bhasin --- Client.xcodeproj/project.pbxproj | 12 +++-- .../xcshareddata/WorkspaceSettings.xcsettings | 2 + .../Breached Website - Medium.pdf | Bin 0 -> 4294 bytes .../Breached Website.pdf | Bin 0 -> 4151 bytes .../Breached Website.imageset/Contents.json | 25 ++++++++++ .../BreachAlertsManager.swift | 15 ++++-- .../Login Management/LoginDataSource.swift | 14 ++---- ...l.swift => LoginDetailTableViewCell.swift} | 20 ++++---- .../LoginListTableViewCell.swift | 46 ++++++++++++++++++ .../LoginListViewController.swift | 5 +- .../Login Management/LoginListViewModel.swift | 34 +++++++++++-- .../Settings/LoginDetailViewController.swift | 22 ++++----- ClientTests/BreachAlertsTests.swift | 38 ++++++++------- ClientTests/LoginsListViewModelTests.swift | 12 ++--- 14 files changed, 176 insertions(+), 69 deletions(-) create mode 100644 Client/Assets/Images.xcassets/Breached Website.imageset/Breached Website - Medium.pdf create mode 100644 Client/Assets/Images.xcassets/Breached Website.imageset/Breached Website.pdf create mode 100644 Client/Assets/Images.xcassets/Breached Website.imageset/Contents.json rename Client/Frontend/Login Management/{LoginTableViewCell.swift => LoginDetailTableViewCell.swift} (92%) create mode 100644 Client/Frontend/Login Management/LoginListTableViewCell.swift diff --git a/Client.xcodeproj/project.pbxproj b/Client.xcodeproj/project.pbxproj index 815198094cbb..fa0826a5b8b4 100644 --- a/Client.xcodeproj/project.pbxproj +++ b/Client.xcodeproj/project.pbxproj @@ -388,6 +388,7 @@ CA77ABFD24773C92005079F9 /* BreachAlertsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA77ABF424772D98005079F9 /* BreachAlertsManager.swift */; }; CA7BD568248189E800A0A61B /* BreachAlertsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA7BD564248185B500A0A61B /* BreachAlertsTests.swift */; }; CA7FC7D324A6A9B70012F347 /* LoginListDataSourceHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA7FC7D224A6A9B70012F347 /* LoginListDataSourceHelper.swift */; }; + CA8226F324C11DB7008A6F38 /* LoginListTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8226F224C11DB7008A6F38 /* LoginListTableViewCell.swift */; }; CA90753824929B22005B794D /* NoLoginsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA90753724929B22005B794D /* NoLoginsView.swift */; }; CAA3B7E62497DCB60094E3C1 /* LoginDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA3B7E52497DCB60094E3C1 /* LoginDataSource.swift */; }; CAC458F1249429C20042561A /* LoginListSelectionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC458F0249429C20042561A /* LoginListSelectionHelper.swift */; }; @@ -607,7 +608,7 @@ E6327A641BF6438E008D12E0 /* DebugSettingsBundleOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6327A631BF6438E008D12E0 /* DebugSettingsBundleOptions.swift */; }; E633E2DA1C21EAF8001FFF6C /* LoginDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E633E2D91C21EAF8001FFF6C /* LoginDetailViewController.swift */; }; E633E37A1C2204BE001FFF6C /* LoginManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E633E3791C2204BE001FFF6C /* LoginManagerTests.swift */; }; - E63ED7D81BFCD9990097D08E /* LoginTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E63ED7D71BFCD9990097D08E /* LoginTableViewCell.swift */; }; + E63ED7D81BFCD9990097D08E /* LoginDetailTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E63ED7D71BFCD9990097D08E /* LoginDetailTableViewCell.swift */; }; E63ED8E11BFD25580097D08E /* LoginListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E63ED8E01BFD25580097D08E /* LoginListViewController.swift */; }; E63F71881DB7FBE200A995C9 /* TestSQLiteMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E63F71871DB7FBE200A995C9 /* TestSQLiteMetadata.swift */; }; E640E85E1C73A45A00C5F072 /* PasscodeEntryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E640E85D1C73A45A00C5F072 /* PasscodeEntryViewController.swift */; }; @@ -1545,6 +1546,7 @@ CA77ABF424772D98005079F9 /* BreachAlertsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreachAlertsManager.swift; sourceTree = ""; }; CA7BD564248185B500A0A61B /* BreachAlertsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreachAlertsTests.swift; sourceTree = ""; }; CA7FC7D224A6A9B70012F347 /* LoginListDataSourceHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginListDataSourceHelper.swift; sourceTree = ""; }; + CA8226F224C11DB7008A6F38 /* LoginListTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginListTableViewCell.swift; sourceTree = ""; }; CA90753724929B22005B794D /* NoLoginsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoLoginsView.swift; sourceTree = ""; }; CAA3B7E52497DCB60094E3C1 /* LoginDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginDataSource.swift; sourceTree = ""; }; CAC458F0249429C20042561A /* LoginListSelectionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginListSelectionHelper.swift; sourceTree = ""; }; @@ -1753,7 +1755,7 @@ E6327A631BF6438E008D12E0 /* DebugSettingsBundleOptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DebugSettingsBundleOptions.swift; sourceTree = ""; }; E633E2D91C21EAF8001FFF6C /* LoginDetailViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = LoginDetailViewController.swift; path = ../Settings/LoginDetailViewController.swift; sourceTree = ""; }; E633E3791C2204BE001FFF6C /* LoginManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginManagerTests.swift; sourceTree = ""; }; - E63ED7D71BFCD9990097D08E /* LoginTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginTableViewCell.swift; sourceTree = ""; }; + E63ED7D71BFCD9990097D08E /* LoginDetailTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginDetailTableViewCell.swift; sourceTree = ""; }; E63ED8E01BFD25580097D08E /* LoginListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginListViewController.swift; sourceTree = ""; }; E63F71871DB7FBE200A995C9 /* TestSQLiteMetadata.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestSQLiteMetadata.swift; sourceTree = ""; }; E640E85D1C73A45A00C5F072 /* PasscodeEntryViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PasscodeEntryViewController.swift; sourceTree = ""; }; @@ -3186,7 +3188,8 @@ isa = PBXGroup; children = ( E63ED8E01BFD25580097D08E /* LoginListViewController.swift */, - E63ED7D71BFCD9990097D08E /* LoginTableViewCell.swift */, + CA8226F224C11DB7008A6F38 /* LoginListTableViewCell.swift */, + E63ED7D71BFCD9990097D08E /* LoginDetailTableViewCell.swift */, E633E2D91C21EAF8001FFF6C /* LoginDetailViewController.swift */, CAA3B7E52497DCB60094E3C1 /* LoginDataSource.swift */, CA520E7924913C1B00CCAB48 /* LoginListViewModel.swift */, @@ -5148,6 +5151,7 @@ E4CD9F6D1A77DD2800318571 /* ReaderModeStyleViewController.swift in Sources */, 5F130D2E2483508E00B0F7D0 /* FxAWebViewModel.swift in Sources */, D0FCF7F51FE45842004A7995 /* UserScriptManager.swift in Sources */, + CA8226F324C11DB7008A6F38 /* LoginListTableViewCell.swift in Sources */, E4A960061ABB9C450069AD6F /* ReaderModeUtils.swift in Sources */, 048B4C8324A53F26001B56E8 /* ButtonWithSublabel.swift in Sources */, 435D660323D793DF0046EFA2 /* UpdateModel.swift in Sources */, @@ -5292,7 +5296,7 @@ EBC4869E2195F58300CDA48D /* AboutHomeHandler.swift in Sources */, DDA24A431FD84D630098F159 /* DefaultSearchPrefs.swift in Sources */, E65075611E37F77D006961AC /* MenuHelper.swift in Sources */, - E63ED7D81BFCD9990097D08E /* LoginTableViewCell.swift in Sources */, + E63ED7D81BFCD9990097D08E /* LoginDetailTableViewCell.swift in Sources */, 66CE54A820FCF6CF00CC310B /* WebsiteDataManagementViewController.swift in Sources */, C8FB0C75232151BA00031088 /* AppMenu.swift in Sources */, C8E2E80E23D20FD2005AACE6 /* FxAWebViewController.swift in Sources */, diff --git a/Client.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/Client.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings index 3ddf867a10ac..530b833587b7 100644 --- a/Client.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ b/Client.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -4,5 +4,7 @@ BuildSystemType Latest + PreviewsEnabled + diff --git a/Client/Assets/Images.xcassets/Breached Website.imageset/Breached Website - Medium.pdf b/Client/Assets/Images.xcassets/Breached Website.imageset/Breached Website - Medium.pdf new file mode 100644 index 0000000000000000000000000000000000000000..3ff64e8515ab2f409eff1163f08c73ded7f254cb GIT binary patch literal 4294 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 iI>;-aF{ Maybe<[LoginRecord]> { - var result: [LoginRecord] = [] + func findUserBreaches(_ logins: [LoginRecord]) -> Maybe> { + var result = Set() if self.breaches.count <= 0 { return Maybe(failure: BreachAlertsError(description: "cannot compare to an empty list of breaches")) @@ -87,7 +94,7 @@ final public class BreachAlertsManager { continue } print("compareToBreaches(): ⚠️ password exposed ⚠️: \(breach.breachDate)") - result.append(item) + result.insert(item) } } print("compareToBreaches(): fin") diff --git a/Client/Frontend/Login Management/LoginDataSource.swift b/Client/Frontend/Login Management/LoginDataSource.swift index 1d18e1390939..96a639ac683e 100644 --- a/Client/Frontend/Login Management/LoginDataSource.swift +++ b/Client/Frontend/Login Management/LoginDataSource.swift @@ -40,11 +40,7 @@ class LoginDataSource: NSObject, UITableViewDataSource { } @objc func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = ThemedTableViewCell(style: .subtitle, reuseIdentifier: CellReuseIdentifier) - - // Need to override the default background multi-select color to support theming - cell.multipleSelectionBackgroundView = UIView() - cell.applyTheme() + let cell = LoginListTableViewCell(style: .subtitle, reuseIdentifier: CellReuseIdentifier, inset: tableView.separatorInset) if indexPath.section == LoginsSettingsSection { let hideSettings = viewModel.searchController?.isActive ?? false || tableView.isEditing @@ -69,12 +65,8 @@ class LoginDataSource: NSObject, UITableViewDataSource { cell.textLabel?.text = login.hostname cell.detailTextColor = UIColor.theme.tableView.rowDetailText cell.detailTextLabel?.text = login.username - cell.accessoryType = .disclosureIndicator - if let breaches = viewModel.userBreaches { - if breaches.contains(login) { - cell.textLabel?.textColor = UIColor.systemRed - viewModel.setBreachIndexPath(indexPath: indexPath) - } + if let breaches = viewModel.userBreaches, breaches.contains(login) { + cell.breachAlertImageView.isHidden = false } } return cell diff --git a/Client/Frontend/Login Management/LoginTableViewCell.swift b/Client/Frontend/Login Management/LoginDetailTableViewCell.swift similarity index 92% rename from Client/Frontend/Login Management/LoginTableViewCell.swift rename to Client/Frontend/Login Management/LoginDetailTableViewCell.swift index a7a67e0d6a96..88e3f39d7482 100644 --- a/Client/Frontend/Login Management/LoginTableViewCell.swift +++ b/Client/Frontend/Login Management/LoginDetailTableViewCell.swift @@ -6,13 +6,13 @@ import UIKit import SnapKit import Storage -protocol LoginTableViewCellDelegate: AnyObject { - func didSelectOpenAndFillForCell(_ cell: LoginTableViewCell) - func shouldReturnAfterEditingDescription(_ cell: LoginTableViewCell) -> Bool - func infoItemForCell(_ cell: LoginTableViewCell) -> InfoItem? +protocol LoginDetailTableViewCellDelegate: AnyObject { + func didSelectOpenAndFillForCell(_ cell: LoginDetailTableViewCell) + func shouldReturnAfterEditingDescription(_ cell: LoginDetailTableViewCell) -> Bool + func infoItemForCell(_ cell: LoginDetailTableViewCell) -> InfoItem? } -private struct LoginTableViewCellUX { +public struct LoginTableViewCellUX { static let highlightedLabelFont = UIFont.systemFont(ofSize: 12) static let highlightedLabelTextColor = UIConstants.SystemBlueColor static let descriptionLabelFont = UIFont.systemFont(ofSize: 16) @@ -25,11 +25,11 @@ enum LoginTableViewCellStyle { case iconAndDescriptionLabel } -class LoginTableViewCell: ThemedTableViewCell { +class LoginDetailTableViewCell: ThemedTableViewCell { fileprivate let labelContainer = UIView() - weak var delegate: LoginTableViewCellDelegate? + weak var delegate: LoginDetailTableViewCellDelegate? // In order for context menu handling, this is required override var canBecomeFirstResponder: Bool { @@ -187,7 +187,7 @@ class LoginTableViewCell: ThemedTableViewCell { } // MARK: - Menu Selectors -extension LoginTableViewCell: MenuHelperInterface { +extension LoginDetailTableViewCell: MenuHelperInterface { func menuHelperReveal() { displayDescriptionAsPassword = false @@ -208,14 +208,14 @@ extension LoginTableViewCell: MenuHelperInterface { } // MARK: - Cell Decorators -extension LoginTableViewCell { +extension LoginDetailTableViewCell { func updateCellWithLogin(_ login: LoginRecord) { descriptionLabel.text = login.hostname highlightedLabel.text = login.username } } -extension LoginTableViewCell: UITextFieldDelegate { +extension LoginDetailTableViewCell: UITextFieldDelegate { func textFieldShouldReturn(_ textField: UITextField) -> Bool { return self.delegate?.shouldReturnAfterEditingDescription(self) ?? true } diff --git a/Client/Frontend/Login Management/LoginListTableViewCell.swift b/Client/Frontend/Login Management/LoginListTableViewCell.swift new file mode 100644 index 000000000000..4d9e70e3635b --- /dev/null +++ b/Client/Frontend/Login Management/LoginListTableViewCell.swift @@ -0,0 +1,46 @@ +/* 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 LoginListTableViewCell: ThemedTableViewCell { + lazy var breachAlertImageView: UIImageView = { + let image = UIImage(named: "Breached Website") + let imageView = UIImageView(image: image) + imageView.isHidden = true + return imageView + }() + let breachAlertSize: CGFloat = 24 + + init(style: UITableViewCell.CellStyle, reuseIdentifier: String?, inset: UIEdgeInsets) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + accessoryType = .disclosureIndicator + contentView.addSubview(breachAlertImageView) + breachAlertImageView.snp.remakeConstraints { make in + make.centerY.equalTo(contentView) + make.trailing.equalTo(contentView.snp.trailing).offset(-LoginTableViewCellUX.HorizontalMargin) + make.width.equalTo(breachAlertSize) + make.height.equalTo(breachAlertSize) + } + + textLabel?.snp.remakeConstraints({ make in + make.leading.equalTo(contentView).offset(inset.left) + make.trailing.equalTo(breachAlertImageView.snp.leading).offset(-LoginTableViewCellUX.HorizontalMargin/2) + make.top.equalTo(inset.top) + make.centerY.equalTo(contentView) + if let detailTextLabel = self.detailTextLabel { + make.bottom.equalTo(detailTextLabel.snp.top) + make.top.equalTo(contentView.snp.top).offset(LoginTableViewCellUX.HorizontalMargin) + } + }) + + // Need to override the default background multi-select color to support theming + self.multipleSelectionBackgroundView = UIView() + self.applyTheme() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Client/Frontend/Login Management/LoginListViewController.swift b/Client/Frontend/Login Management/LoginListViewController.swift index c7541a3f9b90..584d408bb0e9 100644 --- a/Client/Frontend/Login Management/LoginListViewController.swift +++ b/Client/Frontend/Login Management/LoginListViewController.swift @@ -435,7 +435,10 @@ extension LoginListViewController: LoginViewModelDelegate { func breachPathDidUpdate() { DispatchQueue.main.async { - self.tableView.reloadRows(at: self.viewModel.breachIndexPath, with: .right) + self.viewModel.breachIndexPath.forEach { + guard let cell = self.tableView.cellForRow(at: $0) as? LoginListTableViewCell else { return } + cell.breachAlertImageView.isHidden = false + } } } diff --git a/Client/Frontend/Login Management/LoginListViewModel.swift b/Client/Frontend/Login Management/LoginListViewModel.swift index 9f9217b6d829..caba3929f231 100644 --- a/Client/Frontend/Login Management/LoginListViewModel.swift +++ b/Client/Frontend/Login Management/LoginListViewModel.swift @@ -25,8 +25,8 @@ final class LoginListViewModel { } fileprivate let helper = LoginListDataSourceHelper() private(set) var breachAlertsManager = BreachAlertsManager() - private(set) var userBreaches: [LoginRecord]? - private(set) var breachIndexPath = [IndexPath]() { + private(set) var userBreaches: Set? + private(set) var breachIndexPath = Set() { didSet { delegate?.breachPathDidUpdate() } @@ -44,17 +44,26 @@ final class LoginListViewModel { breachAlertsManager.loadBreaches { [weak self] _ in guard let self = self, let logins = self.activeLoginQuery?.value.successValue else { return } self.userBreaches = self.breachAlertsManager.findUserBreaches(logins).successValue + guard let breaches = self.userBreaches else { return } + var indexPaths = Set() + for breach in breaches { + if logins.contains(breach), let indexPath = self.indexPathForLogin(breach) { + indexPaths.insert(indexPath) + } + } + self.breachIndexPath = indexPaths } activeLoginQuery! >>== self.setLogins } - + /// Searches SQLite database for logins that match query. /// Wraps the SQLiteLogins method to allow us to cancel it from our end. func queryLogins(_ query: String) -> Deferred> { let deferred = Deferred>() profile.logins.searchLoginsWithQuery(query) >>== { logins in var log = logins.asArray() - log.append(LoginRecord(fromJSONDict: ["hostname" : "http://abreached.com", "timePasswordChanged": 46800000])) + log.append(LoginRecord(fromJSONDict: ["hostname" : "abreachedwithalongstringname.com", "timePasswordChanged": 46800000])) + log.append(LoginRecord(fromJSONDict: ["hostname" : "abreach.com", "timePasswordChanged": 46800000, "username": "username"])) deferred.fillIfUnfilled(Maybe(success: log)) succeed() } @@ -82,6 +91,14 @@ final class LoginListViewModel { return section[indexPath.row] } + func indexPathForLogin(_ login: LoginRecord) -> IndexPath? { + let title = self.helper.titleForLogin(login) + guard let section = self.titles.firstIndex(of: title), let row = self.loginRecordSections[title]?.firstIndex(of: login) else { + return nil + } + return IndexPath(row: row, section: section+1) + } + func loginsForSection(_ section: Int) -> [LoginRecord]? { guard section > 0 else { assertionFailure() @@ -118,6 +135,10 @@ final class LoginListViewModel { self.breachIndexPath = [indexPath] } + func setBreachAlertsManager(_ client: BreachAlertsClientProtocol) { + self.breachAlertsManager = BreachAlertsManager(client) + } + // MARK: - UX Constants struct LoginListUX { static let RowHeight: CGFloat = 58 @@ -134,8 +155,11 @@ protocol LoginViewModelDelegate: AnyObject { func breachPathDidUpdate() } -extension LoginRecord: Equatable { +extension LoginRecord: Equatable, Hashable { public static func == (lhs: LoginRecord, rhs: LoginRecord) -> Bool { return lhs.id == rhs.id && lhs.hostname == rhs.hostname && lhs.credentials == rhs.credentials } + public func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } } diff --git a/Client/Frontend/Settings/LoginDetailViewController.swift b/Client/Frontend/Settings/LoginDetailViewController.swift index a1008b0d70dd..4cfb0364cf0d 100644 --- a/Client/Frontend/Settings/LoginDetailViewController.swift +++ b/Client/Frontend/Settings/LoginDetailViewController.swift @@ -41,7 +41,7 @@ class LoginDetailViewController: SensitiveViewController { fileprivate weak var usernameField: UITextField? fileprivate weak var passwordField: UITextField? // Used to temporarily store a reference to the cell the user is showing the menu controller for - fileprivate var menuControllerCell: LoginTableViewCell? + fileprivate var menuControllerCell: LoginDetailTableViewCell? fileprivate var deleteAlert: UIAlertController? weak var settingsDelegate: SettingsDelegate? @@ -188,8 +188,8 @@ extension LoginDetailViewController: UITableViewDataSource { } } - fileprivate func cell(forIndexPath indexPath: IndexPath) -> LoginTableViewCell { - let loginCell = LoginTableViewCell() + fileprivate func cell(forIndexPath indexPath: IndexPath) -> LoginDetailTableViewCell { + let loginCell = LoginDetailTableViewCell() loginCell.selectionStyle = .none loginCell.delegate = self return loginCell @@ -218,7 +218,7 @@ extension LoginDetailViewController: UITableViewDelegate { return } - guard let cell = tableView.cellForRow(at: indexPath) as? LoginTableViewCell else { return } + guard let cell = tableView.cellForRow(at: indexPath) as? LoginDetailTableViewCell else { return } cell.becomeFirstResponder() @@ -294,7 +294,7 @@ extension LoginDetailViewController { @objc func edit() { isEditingFieldData = true - guard let cell = tableView.cellForRow(at: InfoItem.usernameItem.indexPath) as? LoginTableViewCell else { return } + guard let cell = tableView.cellForRow(at: InfoItem.usernameItem.indexPath) as? LoginDetailTableViewCell else { return } cell.descriptionLabel.becomeFirstResponder() navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneEditing)) } @@ -327,13 +327,13 @@ extension LoginDetailViewController { } // MARK: - Cell Delegate -extension LoginDetailViewController: LoginTableViewCellDelegate { +extension LoginDetailViewController: LoginDetailTableViewCellDelegate { - fileprivate func cellForItem(_ item: InfoItem) -> LoginTableViewCell? { - return tableView.cellForRow(at: item.indexPath) as? LoginTableViewCell + fileprivate func cellForItem(_ item: InfoItem) -> LoginDetailTableViewCell? { + return tableView.cellForRow(at: item.indexPath) as? LoginDetailTableViewCell } - func didSelectOpenAndFillForCell(_ cell: LoginTableViewCell) { + func didSelectOpenAndFillForCell(_ cell: LoginDetailTableViewCell) { guard let url = (self.login.formSubmitURL?.asURL ?? self.login.hostname.asURL) else { return } @@ -343,7 +343,7 @@ extension LoginDetailViewController: LoginTableViewCellDelegate { }) } - func shouldReturnAfterEditingDescription(_ cell: LoginTableViewCell) -> Bool { + func shouldReturnAfterEditingDescription(_ cell: LoginDetailTableViewCell) -> Bool { let usernameCell = cellForItem(.usernameItem) let passwordCell = cellForItem(.passwordItem) @@ -354,7 +354,7 @@ extension LoginDetailViewController: LoginTableViewCellDelegate { return false } - func infoItemForCell(_ cell: LoginTableViewCell) -> InfoItem? { + func infoItemForCell(_ cell: LoginDetailTableViewCell) -> InfoItem? { if let index = tableView.indexPath(for: cell), let item = InfoItem(rawValue: index.row) { return item diff --git a/ClientTests/BreachAlertsTests.swift b/ClientTests/BreachAlertsTests.swift index d89509fce146..39120ad94d6f 100644 --- a/ClientTests/BreachAlertsTests.swift +++ b/ClientTests/BreachAlertsTests.swift @@ -18,11 +18,19 @@ let mockRecord = BreachRecord( let amockRecord = BreachRecord( name: "MockBreach", title: "A Mock BreachRecord", - domain: "abreached.com", + domain: "abreach.com", breachDate: "1970-01-02", description: "A mock BreachRecord for testing purposes." ) - +let longMock = BreachRecord( + name: "MockBreach", + title: "A Mock BreachRecord", + domain: "abreachedwithalongstringname.com", + breachDate: "1970-01-02", + description: "A mock BreachRecord for testing purposes." +) +let unbreachedLogin = LoginRecord(fromJSONDict: ["hostname" : "http://unbreached.com", "timePasswordChanged": 1594411049000]) +let breachedLogin = LoginRecord(fromJSONDict: ["hostname" : "http://breached.com", "timePasswordChanged": 46800000]) class MockBreachAlertsClient: BreachAlertsClientProtocol { func fetchData(endpoint: BreachAlertsClient.Endpoint, completion: @escaping (Maybe) -> Void) { guard let mockData = try? JSONEncoder().encode([mockRecord].self) else { @@ -35,12 +43,8 @@ class MockBreachAlertsClient: BreachAlertsClientProtocol { class BreachAlertsTests: XCTestCase { var breachAlertsManager: BreachAlertsManager! - let unbreachedLogin = [ - LoginRecord(fromJSONDict: ["hostname" : "http://unbreached.com", "timePasswordChanged": 1594411049000]) - ] - let breachedLogin = [ - LoginRecord(fromJSONDict: ["hostname" : "http://breached.com", "timePasswordChanged": 46800000]) - ] + let unbreachedLoginSet = Set(arrayLiteral: unbreachedLogin) + let breachedLoginSet = Set(arrayLiteral: breachedLogin) override func setUp() { self.breachAlertsManager = BreachAlertsManager(MockBreachAlertsClient()) @@ -51,13 +55,13 @@ class BreachAlertsTests: XCTestCase { XCTAssertTrue(maybeBreaches.isSuccess) XCTAssertNotNil(maybeBreaches.successValue) if let breaches = maybeBreaches.successValue { - XCTAssertEqual([mockRecord, amockRecord], breaches) + XCTAssertEqual([mockRecord, longMock, amockRecord], breaches) } } } func testCompareBreaches() { - let unloadedBreachesOpt = self.breachAlertsManager?.findUserBreaches(breachedLogin) + let unloadedBreachesOpt = self.breachAlertsManager?.findUserBreaches([breachedLogin]) XCTAssertNotNil(unloadedBreachesOpt) if let unloadedBreaches = unloadedBreachesOpt { XCTAssertTrue(unloadedBreaches.isFailure) @@ -70,28 +74,28 @@ class BreachAlertsTests: XCTestCase { XCTAssertTrue(emptyLogins.isFailure) } - let noBreachesOpt = self.breachAlertsManager?.findUserBreaches(self.unbreachedLogin) + let noBreachesOpt = self.breachAlertsManager?.findUserBreaches([unbreachedLogin]) XCTAssertNotNil(noBreachesOpt) if let noBreaches = noBreachesOpt { XCTAssertTrue(noBreaches.isSuccess) XCTAssertEqual(noBreaches.successValue, Optional([])) } - let breachedOpt = self.breachAlertsManager?.findUserBreaches(self.breachedLogin) + let breachedOpt = self.breachAlertsManager?.findUserBreaches([breachedLogin]) XCTAssertNotNil(breachedOpt) if let breached = breachedOpt { XCTAssertTrue(breached.isSuccess) - XCTAssertEqual(breached.successValue, self.breachedLogin) + XCTAssertEqual(breached.successValue, [breachedLogin]) } } } func testLoginsByHostname() { - let unbreached = ["unbreached.com": self.unbreachedLogin] - var result = breachAlertsManager.loginsByHostname(self.unbreachedLogin) + let unbreached = ["unbreached.com": [unbreachedLogin]] + var result = breachAlertsManager.loginsByHostname([unbreachedLogin]) XCTAssertEqual(result, unbreached) - let breached = ["breached.com": self.breachedLogin] - result = breachAlertsManager.loginsByHostname(self.breachedLogin) + let breached = ["breached.com": [breachedLogin]] + result = breachAlertsManager.loginsByHostname([breachedLogin]) XCTAssertEqual(result, breached) } } diff --git a/ClientTests/LoginsListViewModelTests.swift b/ClientTests/LoginsListViewModelTests.swift index 9a60bc84f136..1fc9f958815d 100644 --- a/ClientTests/LoginsListViewModelTests.swift +++ b/ClientTests/LoginsListViewModelTests.swift @@ -17,6 +17,8 @@ class LoginsListViewModelTests: XCTestCase { let searchController = UISearchController() self.viewModel = LoginListViewModel(profile: mockProfile, searchController: searchController) self.dataSource = LoginDataSource(viewModel: self.viewModel) + self.viewModel.setBreachAlertsManager(MockBreachAlertsClient()) + self.addLogins() } private func addLogins() { @@ -41,23 +43,21 @@ class LoginsListViewModelTests: XCTestCase { } func testQueryLogins() { - self.addLogins() - let emptyQueryResult = self.viewModel.queryLogins("") XCTAssertTrue(emptyQueryResult.value.isSuccess) - XCTAssertEqual(emptyQueryResult.value.successValue?.count, 11) + XCTAssertEqual(emptyQueryResult.value.successValue?.count, 12) let exampleQueryResult = self.viewModel.queryLogins("example") XCTAssertTrue(exampleQueryResult.value.isSuccess) - XCTAssertEqual(exampleQueryResult.value.successValue?.count, 11) + XCTAssertEqual(exampleQueryResult.value.successValue?.count, 12) let threeQueryResult = self.viewModel.queryLogins("3") XCTAssertTrue(threeQueryResult.value.isSuccess) - XCTAssertEqual(threeQueryResult.value.successValue?.count, 2) + XCTAssertEqual(threeQueryResult.value.successValue?.count, 3) let zQueryResult = self.viewModel.queryLogins("yxz") XCTAssertTrue(zQueryResult.value.isSuccess) - XCTAssertEqual(zQueryResult.value.successValue?.count, 1) + XCTAssertEqual(zQueryResult.value.successValue?.count, 2) } func testIsDuringSearchControllerDismiss() { From 86280c8548777e92bb4ea321b2aa9026b0b3ce92 Mon Sep 17 00:00:00 2001 From: Vanna Phong Date: Mon, 27 Jul 2020 11:19:13 -0700 Subject: [PATCH 10/14] Fix margins within LoginListTableViewCell (#7022) * Update LoginListTableViewCell.swift and add custom stack views + containers --- .../BreachAlertsManager.swift | 2 +- .../Login Management/LoginDataSource.swift | 13 ++- .../LoginListTableViewCell.swift | 96 +++++++++++++++---- .../Login Management/LoginListViewModel.swift | 2 +- ClientTests/BreachAlertsTests.swift | 2 +- 5 files changed, 87 insertions(+), 28 deletions(-) diff --git a/Client/Frontend/Login Management/BreachAlertsManager.swift b/Client/Frontend/Login Management/BreachAlertsManager.swift index daa769a3a1e9..b9e4c185f72d 100644 --- a/Client/Frontend/Login Management/BreachAlertsManager.swift +++ b/Client/Frontend/Login Management/BreachAlertsManager.swift @@ -52,7 +52,7 @@ final public class BreachAlertsManager { self.breaches.append(BreachRecord( name: "MockBreach", title: "A Mock BreachRecord", - domain: "abreachedwithalongstringname.com", + domain: "abreachedwithalongstringnameaslkdjflskjfas.com", breachDate: "1970-01-02", description: "A mock BreachRecord for testing purposes." )) diff --git a/Client/Frontend/Login Management/LoginDataSource.swift b/Client/Frontend/Login Management/LoginDataSource.swift index 96a639ac683e..5af9a19e0201 100644 --- a/Client/Frontend/Login Management/LoginDataSource.swift +++ b/Client/Frontend/Login Management/LoginDataSource.swift @@ -40,9 +40,10 @@ class LoginDataSource: NSObject, UITableViewDataSource { } @objc func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = LoginListTableViewCell(style: .subtitle, reuseIdentifier: CellReuseIdentifier, inset: tableView.separatorInset) if indexPath.section == LoginsSettingsSection { + let cell = LoginListTableViewSettingsCell(style: .default, reuseIdentifier: CellReuseIdentifier) + let hideSettings = viewModel.searchController?.isActive ?? false || tableView.isEditing let setting = indexPath.row == 0 ? boolSettings.0 : boolSettings.1 setting.onConfigureCell(cell) @@ -60,15 +61,17 @@ class LoginDataSource: NSObject, UITableViewDataSource { cell.accessoryView?.alpha = 1 } } + return cell } else { + let cell = LoginListTableViewCell(style: .subtitle, reuseIdentifier: CellReuseIdentifier, inset: tableView.separatorInset) guard let login = viewModel.loginAtIndexPath(indexPath) else { return cell } - cell.textLabel?.text = login.hostname - cell.detailTextColor = UIColor.theme.tableView.rowDetailText - cell.detailTextLabel?.text = login.username + cell.hostnameLabel.text = login.hostname + cell.usernameLabel.textColor = UIColor.theme.tableView.rowDetailText + cell.usernameLabel.text = login.username != "" ? login.username : "(no username)" if let breaches = viewModel.userBreaches, breaches.contains(login) { cell.breachAlertImageView.isHidden = false } - } return cell + } } } diff --git a/Client/Frontend/Login Management/LoginListTableViewCell.swift b/Client/Frontend/Login Management/LoginListTableViewCell.swift index 4d9e70e3635b..a0ab3215a6f4 100644 --- a/Client/Frontend/Login Management/LoginListTableViewCell.swift +++ b/Client/Frontend/Login Management/LoginListTableViewCell.swift @@ -4,43 +4,99 @@ import UIKit +class LoginListTableViewSettingsCell: ThemedTableViewCell { + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + class LoginListTableViewCell: ThemedTableViewCell { + private let breachAlertSize: CGFloat = 24 lazy var breachAlertImageView: UIImageView = { let image = UIImage(named: "Breached Website") let imageView = UIImageView(image: image) imageView.isHidden = true return imageView }() - let breachAlertSize: CGFloat = 24 + lazy var breachAlertContainer: UIView = { + let view = UIView() + view.addSubview(breachAlertImageView) + view.setContentHuggingPriority(.defaultHigh, for: .horizontal) + return view + }() + lazy var breachMargin: CGFloat = { + return breachAlertSize+LoginTableViewCellUX.HorizontalMargin*2 + }() + + let hostnameLabel = UILabel() + let usernameLabel = UILabel() + private lazy var hostnameContainer: UIView = { + let view = UIView() + view.addSubview(hostnameLabel) + return view + }() + private lazy var usernameContainer: UIView = { + let view = UIView() + view.addSubview(usernameLabel) + return view + }() + private lazy var textStack: UIStackView = { + let stack = UIStackView(arrangedSubviews: [hostnameContainer, usernameContainer]) + stack.axis = .vertical + return stack + }() + private lazy var contentStack: UIStackView = { + let stack = UIStackView(arrangedSubviews: [textStack, breachAlertContainer]) + stack.axis = .horizontal + return stack + }() + + var inset: UIEdgeInsets! init(style: UITableViewCell.CellStyle, reuseIdentifier: String?, inset: UIEdgeInsets) { super.init(style: style, reuseIdentifier: reuseIdentifier) + self.inset = inset accessoryType = .disclosureIndicator - contentView.addSubview(breachAlertImageView) - breachAlertImageView.snp.remakeConstraints { make in - make.centerY.equalTo(contentView) - make.trailing.equalTo(contentView.snp.trailing).offset(-LoginTableViewCellUX.HorizontalMargin) - make.width.equalTo(breachAlertSize) - make.height.equalTo(breachAlertSize) - } - - textLabel?.snp.remakeConstraints({ make in - make.leading.equalTo(contentView).offset(inset.left) - make.trailing.equalTo(breachAlertImageView.snp.leading).offset(-LoginTableViewCellUX.HorizontalMargin/2) - make.top.equalTo(inset.top) - make.centerY.equalTo(contentView) - if let detailTextLabel = self.detailTextLabel { - make.bottom.equalTo(detailTextLabel.snp.top) - make.top.equalTo(contentView.snp.top).offset(LoginTableViewCellUX.HorizontalMargin) - } - }) - + contentView.addSubview(contentStack) // Need to override the default background multi-select color to support theming self.multipleSelectionBackgroundView = UIView() + self.usernameLabel.textColor = self.detailTextColor self.applyTheme() + setConstraints() } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func setConstraints() { + self.contentStack.snp.remakeConstraints { make in + make.top.bottom.trailing.equalTo(contentView) + make.left.equalTo(contentView).inset(self.inset.left) + } + self.hostnameLabel.snp.remakeConstraints { make in + make.bottom.equalTo(self.contentStack.snp.centerY) + make.leading.equalToSuperview() + make.trailing.lessThanOrEqualTo(self.textStack.snp.trailing) + } + self.usernameLabel.snp.remakeConstraints { make in + make.top.equalTo(self.contentStack.snp.centerY) + } + self.breachAlertImageView.snp.remakeConstraints { make in + make.width.height.equalTo(breachAlertSize) + make.center.equalTo(self.breachAlertContainer.snp.center) + } + self.breachAlertContainer.snp.remakeConstraints { make in + make.width.equalTo(breachMargin) + } + } + + override func layoutSubviews() { + super.layoutSubviews() + setConstraints() + } } diff --git a/Client/Frontend/Login Management/LoginListViewModel.swift b/Client/Frontend/Login Management/LoginListViewModel.swift index caba3929f231..7274a93a5857 100644 --- a/Client/Frontend/Login Management/LoginListViewModel.swift +++ b/Client/Frontend/Login Management/LoginListViewModel.swift @@ -62,7 +62,7 @@ final class LoginListViewModel { let deferred = Deferred>() profile.logins.searchLoginsWithQuery(query) >>== { logins in var log = logins.asArray() - log.append(LoginRecord(fromJSONDict: ["hostname" : "abreachedwithalongstringname.com", "timePasswordChanged": 46800000])) + log.append(LoginRecord(fromJSONDict: ["hostname" : "abreachedwithalongstringnameaslkdjflskjfas.com", "timePasswordChanged": 46800000])) log.append(LoginRecord(fromJSONDict: ["hostname" : "abreach.com", "timePasswordChanged": 46800000, "username": "username"])) deferred.fillIfUnfilled(Maybe(success: log)) succeed() diff --git a/ClientTests/BreachAlertsTests.swift b/ClientTests/BreachAlertsTests.swift index 39120ad94d6f..8f549c6eb24d 100644 --- a/ClientTests/BreachAlertsTests.swift +++ b/ClientTests/BreachAlertsTests.swift @@ -25,7 +25,7 @@ let amockRecord = BreachRecord( let longMock = BreachRecord( name: "MockBreach", title: "A Mock BreachRecord", - domain: "abreachedwithalongstringname.com", + domain: "abreachedwithalongstringnameaslkdjflskjfas.com", breachDate: "1970-01-02", description: "A mock BreachRecord for testing purposes." ) From e4f1565894be13ea7261b54a91913ae5024079c1 Mon Sep 17 00:00:00 2001 From: Vanna Phong Date: Thu, 6 Aug 2020 09:55:14 -0700 Subject: [PATCH 11/14] =?UTF-8?q?FXIOS-710=20=E2=81=83=20Create=20breach?= =?UTF-8?q?=20details=20view=20(#7041)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * stubbing * stack views * convert [BreachRecord] to set; basic view population * formatting + string population * UI polish * commenting * button implementation * breach link malformation handling * VoiceOver support * more elegant url handling * use delegate * better delegation * setup function for breach detail view * re-add long login * tests + comments + better accessibility support * spacing + mock data redo --- Client.xcodeproj/project.pbxproj | 6 +- ...serViewController+TabToolbarDelegate.swift | 8 +- .../Login Management/BreachAlertsClient.swift | 1 - .../BreachAlertsDetailView.swift | 202 ++++++++++++++++++ .../BreachAlertsManager.swift | 62 ++++-- .../LoginListTableViewCell.swift | 5 +- .../LoginListViewController.swift | 16 +- .../Login Management/LoginListViewModel.swift | 5 +- .../Settings/AppSettingsOptions.swift | 19 +- .../Settings/LoginDetailViewController.swift | 52 ++++- .../SettingsTableViewController.swift | 2 +- ClientTests/BreachAlertsTests.swift | 33 +-- ClientTests/LoginsListViewModelTests.swift | 8 +- 13 files changed, 362 insertions(+), 57 deletions(-) create mode 100644 Client/Frontend/Login Management/BreachAlertsDetailView.swift diff --git a/Client.xcodeproj/project.pbxproj b/Client.xcodeproj/project.pbxproj index fa0826a5b8b4..cc6272b132f2 100644 --- a/Client.xcodeproj/project.pbxproj +++ b/Client.xcodeproj/project.pbxproj @@ -384,6 +384,7 @@ CA24B52224ABD7D40093848C /* LoginsListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA24B52024ABD7D40093848C /* LoginsListViewModelTests.swift */; }; CA24B53924ABFE250093848C /* LoginsListSelectionHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA24B53824ABFE250093848C /* LoginsListSelectionHelperTests.swift */; }; CA24B53B24ABFE5D0093848C /* LoginsListDataSourceHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA24B53A24ABFE5D0093848C /* LoginsListDataSourceHelperTests.swift */; }; + CA4ACE4924C8C91600F55894 /* BreachAlertsDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA4ACE4824C8C91500F55894 /* BreachAlertsDetailView.swift */; }; CA520E7A24913C1B00CCAB48 /* LoginListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA520E7924913C1B00CCAB48 /* LoginListViewModel.swift */; }; CA77ABFD24773C92005079F9 /* BreachAlertsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA77ABF424772D98005079F9 /* BreachAlertsManager.swift */; }; CA7BD568248189E800A0A61B /* BreachAlertsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA7BD564248185B500A0A61B /* BreachAlertsTests.swift */; }; @@ -1542,6 +1543,7 @@ CA24B52024ABD7D40093848C /* LoginsListViewModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginsListViewModelTests.swift; sourceTree = ""; }; CA24B53824ABFE250093848C /* LoginsListSelectionHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginsListSelectionHelperTests.swift; sourceTree = ""; }; CA24B53A24ABFE5D0093848C /* LoginsListDataSourceHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginsListDataSourceHelperTests.swift; sourceTree = ""; }; + CA4ACE4824C8C91500F55894 /* BreachAlertsDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreachAlertsDetailView.swift; sourceTree = ""; }; CA520E7924913C1B00CCAB48 /* LoginListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginListViewModel.swift; sourceTree = ""; }; CA77ABF424772D98005079F9 /* BreachAlertsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreachAlertsManager.swift; sourceTree = ""; }; CA7BD564248185B500A0A61B /* BreachAlertsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreachAlertsTests.swift; sourceTree = ""; }; @@ -3189,8 +3191,8 @@ children = ( E63ED8E01BFD25580097D08E /* LoginListViewController.swift */, CA8226F224C11DB7008A6F38 /* LoginListTableViewCell.swift */, - E63ED7D71BFCD9990097D08E /* LoginDetailTableViewCell.swift */, E633E2D91C21EAF8001FFF6C /* LoginDetailViewController.swift */, + E63ED7D71BFCD9990097D08E /* LoginDetailTableViewCell.swift */, CAA3B7E52497DCB60094E3C1 /* LoginDataSource.swift */, CA520E7924913C1B00CCAB48 /* LoginListViewModel.swift */, CA7FC7D224A6A9B70012F347 /* LoginListDataSourceHelper.swift */, @@ -3198,6 +3200,7 @@ CAC458F0249429C20042561A /* LoginListSelectionHelper.swift */, CA77ABF424772D98005079F9 /* BreachAlertsManager.swift */, CA03B269247F1D9E00382B62 /* BreachAlertsClient.swift */, + CA4ACE4824C8C91500F55894 /* BreachAlertsDetailView.swift */, ); path = "Login Management"; sourceTree = ""; @@ -5282,6 +5285,7 @@ EBA3B2D02268F40C00728BDB /* SyncMenuButton.swift in Sources */, D31A0FC71A65D6D000DC8C7E /* SearchSuggestClient.swift in Sources */, 435D7CC5246209AA0043ACB9 /* IntroViewControllerV2.swift in Sources */, + CA4ACE4924C8C91600F55894 /* BreachAlertsDetailView.swift in Sources */, A83E5AB71C1D993D0026D912 /* UIPasteboardExtensions.swift in Sources */, D8EFFA0C1FF5B1FA001D3A09 /* NavigationRouter.swift in Sources */, D0E55C4F1FB4FD23006DC274 /* FormPostHelper.swift in Sources */, diff --git a/Client/Frontend/Browser/BrowserViewController/BrowserViewController+TabToolbarDelegate.swift b/Client/Frontend/Browser/BrowserViewController/BrowserViewController+TabToolbarDelegate.swift index 5d63adee1737..58431b45ecfd 100644 --- a/Client/Frontend/Browser/BrowserViewController/BrowserViewController+TabToolbarDelegate.swift +++ b/Client/Frontend/Browser/BrowserViewController/BrowserViewController+TabToolbarDelegate.swift @@ -62,7 +62,7 @@ extension BrowserViewController: TabToolbarDelegate, PhotonActionSheetProtocol { } func tabToolbarDidPressMenu(_ tabToolbar: TabToolbarProtocol, button: UIButton) { - var whatsNewAction: PhotonActionSheetItem? = nil + var whatsNewAction: PhotonActionSheetItem? let showBadgeForWhatsNew = shouldShowWhatsNew() if showBadgeForWhatsNew { // Set the version number of the app, so the What's new will stop showing @@ -87,7 +87,11 @@ extension BrowserViewController: TabToolbarDelegate, PhotonActionSheetProtocol { let viewLogins: PhotonActionSheetItem? = !isLoginsButtonShowing ? nil : PhotonActionSheetItem(title: Strings.LoginsAndPasswordsTitle, iconString: "key", iconType: .Image, iconAlignment: .left, isEnabled: true) { _, _ in guard let navController = self.navigationController else { return } - LoginListViewController.create(authenticateInNavigationController: navController, profile: self.profile, settingsDelegate: self).uponQueue(.main) { loginsVC in + let navigationHandler: ((_ url: URL?) -> Void) = { url in + UIApplication.shared.keyWindow?.rootViewController?.dismiss(animated: true, completion: nil) + self.openURLInNewTab(url) + } + LoginListViewController.create(authenticateInNavigationController: navController, profile: self.profile, settingsDelegate: self, webpageNavigationHandler: navigationHandler).uponQueue(.main) { loginsVC in guard let loginsVC = loginsVC else { return } loginsVC.shownFromAppMenu = true let navController = ThemedNavigationController(rootViewController: loginsVC) diff --git a/Client/Frontend/Login Management/BreachAlertsClient.swift b/Client/Frontend/Login Management/BreachAlertsClient.swift index 9b980928ed9b..66b116a016da 100644 --- a/Client/Frontend/Login Management/BreachAlertsClient.swift +++ b/Client/Frontend/Login Management/BreachAlertsClient.swift @@ -23,7 +23,6 @@ public class BreachAlertsClient: BreachAlertsClientProtocol { /// Makes a network request to an endpoint and hands off the result to a completion handler. public func fetchData(endpoint: Endpoint, completion: @escaping (_ result: Maybe) -> Void) { - // endpoint.rawValue is the url guard let url = URL(string: endpoint.rawValue) else { completion(Maybe(failure: BreachAlertsError(description: "bad endpoint URL"))) return diff --git a/Client/Frontend/Login Management/BreachAlertsDetailView.swift b/Client/Frontend/Login Management/BreachAlertsDetailView.swift new file mode 100644 index 000000000000..6eb76d0f79ee --- /dev/null +++ b/Client/Frontend/Login Management/BreachAlertsDetailView.swift @@ -0,0 +1,202 @@ +/* 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 + +class BreachAlertsDetailView: UIView { + + private let textColor = UIColor.white + private let titleIconSize: CGFloat = 24 + private lazy var titleIconContainerSize: CGFloat = { + return titleIconSize + LoginTableViewCellUX.HorizontalMargin * 2 + }() + + lazy var titleIcon: UIImageView = { + let imageView = UIImageView(image: BreachAlertsManager.icon) + imageView.tintColor = textColor + imageView.accessibilityTraits = .image + imageView.accessibilityLabel = "Breach Alert Icon" + return imageView + }() + + lazy var titleIconContainer: UIView = { + let container = UIView() + container.addSubview(titleIcon) + titleIcon.snp.makeConstraints { make in + make.width.height.equalTo(titleIconSize) + make.center.equalToSuperview() + } + container.snp.makeConstraints { make in + make.width.height.equalTo(titleIconContainerSize) + } + return container + }() + + lazy var titleLabel: UILabel = { + let label = UILabel() + label.font = DynamicFontHelper.defaultHelper.DeviceFontLargeBold + label.textColor = textColor + label.text = Strings.BreachAlertsTitle + label.sizeToFit() + label.isAccessibilityElement = true + label.accessibilityTraits = .staticText + label.accessibilityLabel = Strings.BreachAlertsTitle + return label + }() + + lazy var learnMoreButton: UIButton = { + let button = UIButton() + button.titleLabel?.font = DynamicFontHelper.defaultHelper.DeviceFontLight + button.setTitle(Strings.BreachAlertsLearnMore, for: .normal) + button.setTitleColor(textColor, for: .normal) + button.tintColor = .white + button.isAccessibilityElement = true + button.accessibilityTraits = .button + button.accessibilityLabel = Strings.BreachAlertsLearnMore + return button + }() + + lazy var titleStack: UIStackView = { + let container = UIStackView(arrangedSubviews: [titleIconContainer, titleLabel, learnMoreButton]) + container.axis = .horizontal + return container + }() + + lazy var breachDateLabel: UILabel = { + let label = UILabel() + label.text = Strings.BreachAlertsBreachDate + label.textColor = textColor + label.numberOfLines = 0 + label.font = DynamicFontHelper.defaultHelper.DeviceFontSmallBold + label.isAccessibilityElement = true + label.accessibilityTraits = .staticText + label.accessibilityLabel = Strings.BreachAlertsBreachDate + return label + }() + + lazy var descriptionLabel: UILabel = { + let label = UILabel() + label.text = Strings.BreachAlertsDescription + label.numberOfLines = 0 + label.textColor = textColor + label.font = DynamicFontHelper.defaultHelper.DeviceFontSmall + label.isAccessibilityElement = true + label.accessibilityTraits = .staticText + return label + }() + + lazy var goToButton: UILabel = { + let button = UILabel() + button.font = DynamicFontHelper.defaultHelper.DeviceFontSmallBold + button.textColor = textColor + button.numberOfLines = 0 + button.isUserInteractionEnabled = true + button.text = Strings.BreachAlertsLink + button.isAccessibilityElement = true + button.accessibilityTraits = .button + button.accessibilityLabel = Strings.BreachAlertsLink + return button + }() + + private lazy var infoStack: UIStackView = { + let stack = UIStackView(arrangedSubviews: [breachDateLabel, descriptionLabel, goToButton]) + stack.distribution = .fillProportionally + stack.axis = .vertical + return stack + }() + + private lazy var contentStack: UIStackView = { + let stack = UIStackView(arrangedSubviews: [titleStack, infoStack]) + stack.axis = .vertical + return stack + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + self.backgroundColor = BreachAlertsManager.detailColor + self.layer.cornerRadius = 5 + self.layer.masksToBounds = true + + self.isAccessibilityElement = false + self.accessibilityElements = [titleLabel, learnMoreButton, breachDateLabel, descriptionLabel, goToButton] + + self.addSubview(contentStack) + self.configureView(for: self.traitCollection) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + titleStack.snp.remakeConstraints { make in + make.leading.trailing.equalToSuperview() + } + infoStack.snp.remakeConstraints { make in + make.leading.equalToSuperview().inset(titleIconContainerSize) + make.trailing.equalToSuperview() + } + contentStack.snp.remakeConstraints { make in + make.bottom.trailing.equalToSuperview().inset(LoginTableViewCellUX.HorizontalMargin) + make.leading.top.equalToSuperview() + } + self.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) + } + + // Populate the view with information from a BreachRecord. + public func setup(_ breach: BreachRecord) { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + guard let date = dateFormatter.date(from: breach.breachDate) else { return } + dateFormatter.dateStyle = .medium + self.breachDateLabel.text! += " \(dateFormatter.string(from: date))." + let goToText = Strings.BreachAlertsLink + " \(breach.domain)" + let attributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: UIColor.white, + .underlineStyle: NSUnderlineStyle.single.rawValue + ] + let attributedText = NSMutableAttributedString(string: goToText, attributes: attributes) + self.goToButton.attributedText = attributedText + self.goToButton.sizeToFit() + self.layoutIfNeeded() + + self.goToButton.accessibilityValue = breach.domain + self.breachDateLabel.accessibilityValue = "\(dateFormatter.string(from: date))." + } + + // MARK: - Dynamic Type Support + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if previousTraitCollection?.preferredContentSizeCategory != self.traitCollection.preferredContentSizeCategory { + configureView(for: self.traitCollection) + } + } + + // If large fonts are enabled, set the title stack vertically. + // Else, set the title stack horizontally. + private func configureView(for traitCollection: UITraitCollection) { + let contentSize = traitCollection.preferredContentSizeCategory + if contentSize.isAccessibilityCategory { + self.titleStack.axis = .vertical + self.titleStack.alignment = .leading + self.titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(self.titleIconSize) + } + self.learnMoreButton.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(self.titleIconSize) + } + } else { + self.titleStack.axis = .horizontal + self.titleStack.alignment = .leading + self.titleLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + } + self.learnMoreButton.snp.makeConstraints { make in + make.centerY.equalToSuperview() + } + } + } +} diff --git a/Client/Frontend/Login Management/BreachAlertsManager.swift b/Client/Frontend/Login Management/BreachAlertsManager.swift index b9e4c185f72d..961cb00ecd07 100644 --- a/Client/Frontend/Login Management/BreachAlertsManager.swift +++ b/Client/Frontend/Login Management/BreachAlertsManager.swift @@ -25,7 +25,11 @@ struct BreachRecord: Codable, Equatable, Hashable { /// A manager for the user's breached login information, if any. final public class BreachAlertsManager { - var breaches: [BreachRecord] = [] + static let icon = UIImage(named: "Breached Website")?.withRenderingMode(.alwaysTemplate) + static let listColor = UIColor(red: 0.78, green: 0.16, blue: 0.18, alpha: 1.00) + static let detailColor = UIColor(red: 0.59, green: 0.11, blue: 0.11, alpha: 1.00) + static let monitorAboutUrl = URL(string: "https://monitor.firefox.com/about") + var breaches = Set() var breachAlertsClient: BreachAlertsClientProtocol init(_ client: BreachAlertsClientProtocol = BreachAlertsClient()) { @@ -35,31 +39,37 @@ final public class BreachAlertsManager { /// Loads breaches from Monitor endpoint using BreachAlertsClient. /// - Parameters: /// - completion: a completion handler for the processed breaches - func loadBreaches(completion: @escaping (Maybe<[BreachRecord]>) -> Void) { - print("loadBreaches(): called") - + func loadBreaches(completion: @escaping (Maybe>) -> Void) { self.breachAlertsClient.fetchData(endpoint: .breachedAccounts) { maybeData in guard let data = maybeData.successValue else { completion(Maybe(failure: BreachAlertsError(description: "failed to load breaches data"))) return } - guard let decoded = try? JSONDecoder().decode([BreachRecord].self, from: data) else { + guard let decoded = try? JSONDecoder().decode(Set.self, from: data) else { completion(Maybe(failure: BreachAlertsError(description: "JSON data decode failure"))) return } self.breaches = decoded - self.breaches.append(BreachRecord( + // remove for release + self.breaches.insert(BreachRecord( + name: "MockBreach", + title: "A Mock Blockbuster Record", + domain: "blockbuster.com", + breachDate: "1970-01-02", + description: "A mock BreachRecord for testing purposes." + )) + self.breaches.insert(BreachRecord( name: "MockBreach", - title: "A Mock BreachRecord", - domain: "abreachedwithalongstringnameaslkdjflskjfas.com", + title: "A Mock Lorem Ipsum Record", + domain: "lipsum.com", breachDate: "1970-01-02", description: "A mock BreachRecord for testing purposes." )) - self.breaches.append(BreachRecord( + self.breaches.insert(BreachRecord( name: "MockBreach", - title: "A Mock BreachRecord", - domain: "abreach.com", + title: "A Mock Swift Breach Record", + domain: "swift.org", breachDate: "1970-01-02", description: "A mock BreachRecord for testing purposes." )) @@ -83,24 +93,27 @@ final public class BreachAlertsManager { let loginsDictionary = loginsByHostname(logins) for breach in self.breaches { - guard let potentialBreaches = loginsDictionary[breach.domain] else { + guard let potentialUserBreaches = loginsDictionary[breach.domain] else { continue } - for item in potentialBreaches { + for item in potentialUserBreaches { let pwLastChanged = TimeInterval(item.timePasswordChanged/1000) let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" guard let breachDate = dateFormatter.date(from: breach.breachDate)?.timeIntervalSince1970, pwLastChanged < breachDate else { continue } - print("compareToBreaches(): ⚠️ password exposed ⚠️: \(breach.breachDate)") result.insert(item) } } - print("compareToBreaches(): fin") return Maybe(success: result) } + /// Helper function to create a dictionary of LoginRecords separated by hostname. + /// - Parameters: + /// - logins: an array of LoginRecords to sort. + /// - Returns: + /// - a dictionary of [String(): [LoginRecord]]. func loginsByHostname(_ logins: [LoginRecord]) -> [String: [LoginRecord]] { var result = [String: [LoginRecord]]() for login in logins { @@ -114,6 +127,25 @@ final public class BreachAlertsManager { return result } + /// Helper function to find a breach associated with a LoginRecord. + /// - Parameters: + /// - login: an array of LoginRecords to sort. + /// - Returns: + /// - the first BreachRecord associated with login, if any. + func breachRecordForLogin(_ login: LoginRecord) -> BreachRecord? { + let baseDomain = self.baseDomainForLogin(login) + for breach in self.breaches where breach.domain == baseDomain { + let pwLastChanged = TimeInterval(login.timePasswordChanged/1000) + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + guard let breachDate = dateFormatter.date(from: breach.breachDate)?.timeIntervalSince1970, pwLastChanged < breachDate else { + continue + } + return breach + } + return nil + } + private func baseDomainForLogin(_ login: LoginRecord) -> String { guard let result = login.hostname.asURL?.baseDomain else { return login.hostname } return result diff --git a/Client/Frontend/Login Management/LoginListTableViewCell.swift b/Client/Frontend/Login Management/LoginListTableViewCell.swift index a0ab3215a6f4..7ac8c6cbd2a6 100644 --- a/Client/Frontend/Login Management/LoginListTableViewCell.swift +++ b/Client/Frontend/Login Management/LoginListTableViewCell.swift @@ -17,8 +17,8 @@ class LoginListTableViewSettingsCell: ThemedTableViewCell { class LoginListTableViewCell: ThemedTableViewCell { private let breachAlertSize: CGFloat = 24 lazy var breachAlertImageView: UIImageView = { - let image = UIImage(named: "Breached Website") - let imageView = UIImageView(image: image) + let imageView = UIImageView(image: BreachAlertsManager.icon) + imageView.tintColor = BreachAlertsManager.listColor imageView.isHidden = true return imageView }() @@ -64,6 +64,7 @@ class LoginListTableViewCell: ThemedTableViewCell { contentView.addSubview(contentStack) // Need to override the default background multi-select color to support theming self.multipleSelectionBackgroundView = UIView() + self.hostnameLabel.textColor = UIColor.theme.tableView.rowText self.usernameLabel.textColor = self.detailTextColor self.applyTheme() setConstraints() diff --git a/Client/Frontend/Login Management/LoginListViewController.swift b/Client/Frontend/Login Management/LoginListViewController.swift index 584d408bb0e9..e1730b6b483b 100644 --- a/Client/Frontend/Login Management/LoginListViewController.swift +++ b/Client/Frontend/Login Management/LoginListViewController.swift @@ -40,6 +40,7 @@ class LoginListViewController: SensitiveViewController { weak var settingsDelegate: SettingsDelegate? var shownFromAppMenu: Bool = false + var webpageNavigationHandler: ((_ url: URL?) -> Void)? // Titles for selection/deselect/delete buttons fileprivate let deselectAllTitle = NSLocalizedString("Deselect All", tableName: "LoginManager", comment: "Label for the button used to deselect all logins.") @@ -58,13 +59,13 @@ class LoginListViewController: SensitiveViewController { return prefs.boolForKey(PrefsKeys.LoginsShowShortcutMenuItem) ?? true } - static func create(authenticateInNavigationController navigationController: UINavigationController, profile: Profile, settingsDelegate: SettingsDelegate) -> Deferred { + static func create(authenticateInNavigationController navigationController: UINavigationController, profile: Profile, settingsDelegate: SettingsDelegate, webpageNavigationHandler: ((_ url: URL?) -> Void)?) -> Deferred { let deferred = Deferred() func fillDeferred(ok: Bool) { if ok { LeanPlumClient.shared.track(event: .openedLogins) - let viewController = LoginListViewController(profile: profile) + let viewController = LoginListViewController(profile: profile, webpageNavigationHandler: webpageNavigationHandler) viewController.settingsDelegate = settingsDelegate deferred.fill(viewController) } else { @@ -95,9 +96,10 @@ class LoginListViewController: SensitiveViewController { return deferred } - private init(profile: Profile) { + private init(profile: Profile, webpageNavigationHandler: ((_ url: URL?) -> Void)?) { self.viewModel = LoginListViewModel(profile: profile, searchController: searchController) self.loginDataSource = LoginDataSource(viewModel: self.viewModel) + self.webpageNavigationHandler = webpageNavigationHandler super.init(nibName: nil, bundle: nil) } @@ -377,7 +379,12 @@ extension LoginListViewController: UITableViewDelegate { toggleDeleteBarButton() } else if let login = viewModel.loginAtIndexPath(indexPath) { tableView.deselectRow(at: indexPath, animated: true) - let detailViewController = LoginDetailViewController(profile: viewModel.profile, login: login) + let detailViewController = LoginDetailViewController(profile: viewModel.profile, login: login, webpageNavigationHandler: webpageNavigationHandler) + if viewModel.breachIndexPath.contains(indexPath) { + guard let login = viewModel.loginAtIndexPath(indexPath) else { return } + let breach = viewModel.breachAlertsManager.breachRecordForLogin(login) + detailViewController.setBreachRecord(breach: breach) + } detailViewController.settingsDelegate = settingsDelegate navigationController?.pushViewController(detailViewController, animated: true) } @@ -438,6 +445,7 @@ extension LoginListViewController: LoginViewModelDelegate { self.viewModel.breachIndexPath.forEach { guard let cell = self.tableView.cellForRow(at: $0) as? LoginListTableViewCell else { return } cell.breachAlertImageView.isHidden = false + cell.accessibilityValue = "Breached Login Alert" } } } diff --git a/Client/Frontend/Login Management/LoginListViewModel.swift b/Client/Frontend/Login Management/LoginListViewModel.swift index 7274a93a5857..36ccffc1d390 100644 --- a/Client/Frontend/Login Management/LoginListViewModel.swift +++ b/Client/Frontend/Login Management/LoginListViewModel.swift @@ -62,8 +62,9 @@ final class LoginListViewModel { let deferred = Deferred>() profile.logins.searchLoginsWithQuery(query) >>== { logins in var log = logins.asArray() - log.append(LoginRecord(fromJSONDict: ["hostname" : "abreachedwithalongstringnameaslkdjflskjfas.com", "timePasswordChanged": 46800000])) - log.append(LoginRecord(fromJSONDict: ["hostname" : "abreach.com", "timePasswordChanged": 46800000, "username": "username"])) + log.append(LoginRecord(fromJSONDict: ["hostname" : "https://blockbuster.com", "timePasswordChanged": 46800000, "password": "ipsum"])) + log.append(LoginRecord(fromJSONDict: ["hostname" : "https://lipsum.com", "timePasswordChanged": 46800000, "username": "lorem", "password": "ipsum"])) + log.append(LoginRecord(fromJSONDict: ["hostname" : "https://swift.org", "timePasswordChanged": 46800000, "username": "username", "password": "ipsum"])) deferred.fillIfUnfilled(Maybe(success: log)) succeed() } diff --git a/Client/Frontend/Settings/AppSettingsOptions.swift b/Client/Frontend/Settings/AppSettingsOptions.swift index 37aad79dc477..27928d9e1cf5 100644 --- a/Client/Frontend/Settings/AppSettingsOptions.swift +++ b/Client/Frontend/Settings/AppSettingsOptions.swift @@ -607,7 +607,7 @@ class VersionSetting: Setting { } override var title: NSAttributedString? { - return NSAttributedString(string: String(format: NSLocalizedString("Version %@ (%@)", comment: "Version number of Firefox shown in settings"), VersionSetting.appVersion, VersionSetting.appBuildNumber), attributes: [NSAttributedString.Key.foregroundColor: UIColor.theme.tableView.rowText]) + return NSAttributedString(string: String(format: NSLocalizedString("Version %@ (%@)", comment: "Version number of Firefox shown in settings"), VersionSetting.appVersion, VersionSetting.appBuildNumber), attributes: [NSAttributedString.Key.foregroundColor: UIColor.theme.tableView.rowText]) } public static var appVersion: String { @@ -666,7 +666,7 @@ class LicenseAndAcknowledgementsSetting: Setting { } override func onClick(_ navigationController: UINavigationController?) { - setUpAndPushSettingsContentViewController(navigationController) + setUpAndPushSettingsContentViewController(navigationController, self.url) } } @@ -682,7 +682,7 @@ class YourRightsSetting: Setting { } override func onClick(_ navigationController: UINavigationController?) { - setUpAndPushSettingsContentViewController(navigationController) + setUpAndPushSettingsContentViewController(navigationController, self.url) } } @@ -715,7 +715,7 @@ class SendFeedbackSetting: Setting { } override func onClick(_ navigationController: UINavigationController?) { - setUpAndPushSettingsContentViewController(navigationController) + setUpAndPushSettingsContentViewController(navigationController, self.url) } } @@ -744,7 +744,7 @@ class SendAnonymousUsageDataSetting: BoolSetting { } override func onClick(_ navigationController: UINavigationController?) { - setUpAndPushSettingsContentViewController(navigationController) + setUpAndPushSettingsContentViewController(navigationController, self.url) } } @@ -819,7 +819,12 @@ class LoginsSetting: Setting { deselectRow() guard let navController = navigationController else { return } - LoginListViewController.create(authenticateInNavigationController: navController, profile: profile, settingsDelegate: BrowserViewController.foregroundBVC()).uponQueue(.main) { loginsVC in + let navigationHandler: ((_ url: URL?) -> Void) = { url in + guard let url = url else { return } + UIApplication.shared.keyWindow?.rootViewController?.dismiss(animated: true, completion: nil) + self.delegate?.settingsOpenURLInNewTab(url) + } + LoginListViewController.create(authenticateInNavigationController: navController, profile: profile, settingsDelegate: BrowserViewController.foregroundBVC(), webpageNavigationHandler: navigationHandler).uponQueue(.main) { loginsVC in guard let loginsVC = loginsVC else { return } LeanPlumClient.shared.track(event: .openedLogins) navController.pushViewController(loginsVC, animated: true) @@ -915,7 +920,7 @@ class PrivacyPolicySetting: Setting { } override func onClick(_ navigationController: UINavigationController?) { - setUpAndPushSettingsContentViewController(navigationController) + setUpAndPushSettingsContentViewController(navigationController, self.url) } } diff --git a/Client/Frontend/Settings/LoginDetailViewController.swift b/Client/Frontend/Settings/LoginDetailViewController.swift index 4cfb0364cf0d..c71eec44d197 100644 --- a/Client/Frontend/Settings/LoginDetailViewController.swift +++ b/Client/Frontend/Settings/LoginDetailViewController.swift @@ -8,7 +8,8 @@ import Shared import SwiftKeychainWrapper enum InfoItem: Int { - case websiteItem = 0 + case breachItem = 0 + case websiteItem case usernameItem case passwordItem case lastModifiedSeparator @@ -44,12 +45,13 @@ class LoginDetailViewController: SensitiveViewController { fileprivate var menuControllerCell: LoginDetailTableViewCell? fileprivate var deleteAlert: UIAlertController? weak var settingsDelegate: SettingsDelegate? - + fileprivate var breach: BreachRecord? fileprivate var login: LoginRecord { didSet { tableView.reloadData() } } + var webpageNavigationHandler: ((_ url: URL?) -> Void)? fileprivate var isEditingFieldData: Bool = false { didSet { @@ -59,14 +61,19 @@ class LoginDetailViewController: SensitiveViewController { } } - init(profile: Profile, login: LoginRecord) { + init(profile: Profile, login: LoginRecord, webpageNavigationHandler: ((_ url: URL?) -> Void)?) { self.login = login self.profile = profile + self.webpageNavigationHandler = webpageNavigationHandler super.init(nibName: nil, bundle: nil) NotificationCenter.default.addObserver(self, selector: #selector(dismissAlertController), name: UIApplication.didEnterBackgroundNotification, object: nil) } + func setBreachRecord(breach: BreachRecord?) { + self.breach = breach + } + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -77,9 +84,11 @@ class LoginDetailViewController: SensitiveViewController { navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .edit, target: self, action: #selector(edit)) view.addSubview(tableView) + tableView.snp.makeConstraints { make in make.edges.equalTo(self.view) } + tableView.estimatedRowHeight = 44.0 } override func viewWillAppear(_ animated: Bool) { @@ -129,6 +138,26 @@ extension LoginDetailViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { switch InfoItem(rawValue: indexPath.row)! { + case .breachItem: + let breachCell = cell(forIndexPath: indexPath) + guard let breach = self.breach else { return breachCell } + breachCell.isHidden = false + let breachDetailView = BreachAlertsDetailView() + breachCell.contentView.addSubview(breachDetailView) + breachDetailView.snp.makeConstraints { make in + make.edges.equalTo(breachCell.contentView).inset(LoginTableViewCellUX.HorizontalMargin) + } + breachDetailView.setup(breach) + + breachDetailView.learnMoreButton.addTarget(self, action: #selector(didTapBreachLearnMore), for: .touchUpInside) + let breachLinkGesture = UITapGestureRecognizer(target: self, action: #selector(didTapBreachLink(_:))) + breachDetailView.goToButton.addGestureRecognizer(breachLinkGesture) + breachCell.isAccessibilityElement = false + breachCell.contentView.accessibilityElementsHidden = true + breachCell.accessibilityElements = [breachDetailView] + + return breachCell + case .usernameItem: let loginCell = cell(forIndexPath: indexPath) loginCell.highlightedLabelTitle = NSLocalizedString("Username", tableName: "LoginManager", comment: "Label displayed above the username row in Login Detail View.") @@ -206,7 +235,7 @@ extension LoginDetailViewController: UITableViewDataSource { } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return 5 + return 6 } } @@ -238,6 +267,9 @@ extension LoginDetailViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { switch InfoItem(rawValue: indexPath.row)! { + case .breachItem: + guard let _ = self.breach else { return 0 } + return UITableView.automaticDimension case .usernameItem, .passwordItem, .websiteItem: return LoginDetailUX.InfoRowHeight case .lastModifiedSeparator: @@ -271,6 +303,18 @@ extension LoginDetailViewController { self.deleteAlert?.dismiss(animated: false, completion: nil) } + @objc func didTapBreachLearnMore() { + webpageNavigationHandler?(BreachAlertsManager.monitorAboutUrl) + } + + @objc func didTapBreachLink(_ sender: UITapGestureRecognizer? = nil) { + guard let domain = self.breach?.domain else { return } + var urlComponents = URLComponents() + urlComponents.host = domain + urlComponents.scheme = "https" + webpageNavigationHandler?(urlComponents.url) + } + func deleteLogin() { profile.logins.hasSyncedLogins().uponQueue(.main) { yes in self.deleteAlert = UIAlertController.deleteLoginAlertWithDeleteCallback({ [unowned self] _ in diff --git a/Client/Frontend/Settings/SettingsTableViewController.swift b/Client/Frontend/Settings/SettingsTableViewController.swift index cb45555c4527..e061cea483ee 100644 --- a/Client/Frontend/Settings/SettingsTableViewController.swift +++ b/Client/Frontend/Settings/SettingsTableViewController.swift @@ -110,7 +110,7 @@ class Setting: NSObject { func onLongPress(_ navigationController: UINavigationController?) { return } // Helper method to set up and push a SettingsContentViewController - func setUpAndPushSettingsContentViewController(_ navigationController: UINavigationController?) { + func setUpAndPushSettingsContentViewController(_ navigationController: UINavigationController?, _ url: URL? = nil) { if let url = self.url { let viewController = SettingsContentViewController() viewController.settingsTitle = self.title diff --git a/ClientTests/BreachAlertsTests.swift b/ClientTests/BreachAlertsTests.swift index 8f549c6eb24d..ea876c75ec72 100644 --- a/ClientTests/BreachAlertsTests.swift +++ b/ClientTests/BreachAlertsTests.swift @@ -7,33 +7,33 @@ import Storage import Shared import XCTest -let mockRecord = BreachRecord( +let blockbusterBreach = BreachRecord( name: "MockBreach", - title: "A Mock BreachRecord", - domain: "breached.com", + title: "A Mock Blockbuster Record", + domain: "blockbuster.com", breachDate: "1970-01-02", description: "A mock BreachRecord for testing purposes." ) // remove for official release -let amockRecord = BreachRecord( +let lipsumBreach = BreachRecord( name: "MockBreach", - title: "A Mock BreachRecord", - domain: "abreach.com", + title: "A Mock Lorem Ipsum Record", + domain: "lipsum.com", breachDate: "1970-01-02", description: "A mock BreachRecord for testing purposes." ) -let longMock = BreachRecord( +let longBreach = BreachRecord( name: "MockBreach", - title: "A Mock BreachRecord", - domain: "abreachedwithalongstringnameaslkdjflskjfas.com", + title: "A Mock Swift Breach Record", + domain: "swift.org", breachDate: "1970-01-02", description: "A mock BreachRecord for testing purposes." ) let unbreachedLogin = LoginRecord(fromJSONDict: ["hostname" : "http://unbreached.com", "timePasswordChanged": 1594411049000]) -let breachedLogin = LoginRecord(fromJSONDict: ["hostname" : "http://breached.com", "timePasswordChanged": 46800000]) +let breachedLogin = LoginRecord(fromJSONDict: ["hostname" : "http://blockbuster.com", "timePasswordChanged": 46800000]) class MockBreachAlertsClient: BreachAlertsClientProtocol { func fetchData(endpoint: BreachAlertsClient.Endpoint, completion: @escaping (Maybe) -> Void) { - guard let mockData = try? JSONEncoder().encode([mockRecord].self) else { + guard let mockData = try? JSONEncoder().encode([blockbusterBreach].self) else { completion(Maybe(failure: BreachAlertsError(description: "failed to encode mockRecord"))) return } @@ -55,7 +55,7 @@ class BreachAlertsTests: XCTestCase { XCTAssertTrue(maybeBreaches.isSuccess) XCTAssertNotNil(maybeBreaches.successValue) if let breaches = maybeBreaches.successValue { - XCTAssertEqual([mockRecord, longMock, amockRecord], breaches) + XCTAssertEqual([blockbusterBreach, longBreach, lipsumBreach], breaches) } } } @@ -94,8 +94,13 @@ class BreachAlertsTests: XCTestCase { let unbreached = ["unbreached.com": [unbreachedLogin]] var result = breachAlertsManager.loginsByHostname([unbreachedLogin]) XCTAssertEqual(result, unbreached) - let breached = ["breached.com": [breachedLogin]] + let blockbuster = ["blockbuster.com": [breachedLogin]] result = breachAlertsManager.loginsByHostname([breachedLogin]) - XCTAssertEqual(result, breached) + XCTAssertEqual(result, blockbuster) + } + + func testBreachRecordForLogin() { + breachAlertsManager.loadBreaches { _ in } + XCTAssertEqual(blockbusterBreach, breachAlertsManager.breachRecordForLogin(breachedLogin)) } } diff --git a/ClientTests/LoginsListViewModelTests.swift b/ClientTests/LoginsListViewModelTests.swift index 1fc9f958815d..db0a68f5e7e8 100644 --- a/ClientTests/LoginsListViewModelTests.swift +++ b/ClientTests/LoginsListViewModelTests.swift @@ -45,19 +45,19 @@ class LoginsListViewModelTests: XCTestCase { func testQueryLogins() { let emptyQueryResult = self.viewModel.queryLogins("") XCTAssertTrue(emptyQueryResult.value.isSuccess) - XCTAssertEqual(emptyQueryResult.value.successValue?.count, 12) + XCTAssertEqual(emptyQueryResult.value.successValue?.count, 13) let exampleQueryResult = self.viewModel.queryLogins("example") XCTAssertTrue(exampleQueryResult.value.isSuccess) - XCTAssertEqual(exampleQueryResult.value.successValue?.count, 12) + XCTAssertEqual(exampleQueryResult.value.successValue?.count, 13) let threeQueryResult = self.viewModel.queryLogins("3") XCTAssertTrue(threeQueryResult.value.isSuccess) - XCTAssertEqual(threeQueryResult.value.successValue?.count, 3) + XCTAssertEqual(threeQueryResult.value.successValue?.count, 4) let zQueryResult = self.viewModel.queryLogins("yxz") XCTAssertTrue(zQueryResult.value.isSuccess) - XCTAssertEqual(zQueryResult.value.successValue?.count, 2) + XCTAssertEqual(zQueryResult.value.successValue?.count, 3) } func testIsDuringSearchControllerDismiss() { From b29cb9c3ea072a18deb5912a5650dad017732b36 Mon Sep 17 00:00:00 2001 From: Vanna Phong Date: Thu, 13 Aug 2020 11:03:12 -0700 Subject: [PATCH 12/14] UX updates (#7127) --- .../Breached Website.pdf | Bin 4151 -> 0 bytes .../Breached Website.imageset/Contents.json | 3 +-- ... Website - Medium.pdf => warning icon.pdf} | Bin 4294 -> 5989 bytes .../BreachAlertsDetailView.swift | 14 ++++++++++---- .../BreachAlertsManager.swift | 4 ++-- .../Login Management/LoginDataSource.swift | 5 +++++ .../LoginListTableViewCell.swift | 1 - 7 files changed, 18 insertions(+), 9 deletions(-) delete mode 100644 Client/Assets/Images.xcassets/Breached Website.imageset/Breached Website.pdf rename Client/Assets/Images.xcassets/Breached Website.imageset/{Breached Website - Medium.pdf => warning icon.pdf} (51%) diff --git a/Client/Assets/Images.xcassets/Breached Website.imageset/Breached Website.pdf b/Client/Assets/Images.xcassets/Breached Website.imageset/Breached Website.pdf deleted file mode 100644 index b6c248c52d55a486ddae9a2d74e1a25d04dac206..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4151 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 iI>;-aF{igiW9?Ja&MwsN~Gqfl-rSV zN{L)^zm!yFB$N&{F8LLmTu(@HhNU{yzt;b)ckR8_^F8nTe*4{PJcg zJ>Lz=4M!j-0K#IdYw)TNEfv7?B7}s^Z_BLu;QeaP)kstM<(VyGuz}PAImnI1w{<8P zgDwvCHFoBNTt7Wkhto;DBfFQvZojF%Ln+l=a3kwJ@l3>PHJiWRa3R++`Tco2Gt!64 z!Z<=H>ywR#Dts!>Fi!lb##F^xaTO!if|go>$-0e}H)T6^Z0?sy+AR(3K6aaayQFom zb3wg9o2++v)M(*7lKjg+Uo$DWm7T>;qWsXk4eP3nWHaL3;s@c*Bxfzj@YSc^Hg|h4 zyyQac5tc{CgvauU_i21*=MQ5_ZZYX-%qV$!eC$LBeR-wJZz`jvIWEG`GA?V2Zui8^ z@8f*HF*a3za7BLD(4+VPO>n^_Vwns9hQ~93uGJw-tBH(VGE7%m3 zE}tO7?0NC%GNvQ_pu(02Dd}!8RNY41VH>#^yMp>XdeQZ54chf{D=OCUUJM-$d}imY zbCv)kI*T#!t>nzwTivFR3FJHt0wD4Y_?4kP~WFG>EXVFM5W^YIU z9l(64fN|h>u@@0D2GdVeP4rw~ARqu^Y|ID-+_mt4D6l%Bpa2+qI*Y+!(&#LJwAd44 z&SY=`77yY8VD>Vp{;U8hlkQIo0WiB900>-QAutvIo5>Niwc-K?sE1kfrL(fwOghyM z5iDjMSFrK~xMn%)M(2v=3`Nyc8alv4O!}VH{O+F5s6^=^rRL_yD2cxKBl8|0HMU5< zdir$o#a#F7YntqHM(w8`o>e|)W|X$Jh#G6y1Rvz7_}4rz9#8FPr`c9=8cs3AQm2#? z7e=#nspZm?h)Yv~K%FmxBk{7uwKcH-1TB8xcSD7+OZ%B z76kEnSFYQ(m}U=9zQVZwQ3lU5lQ7~&JG#dyId1&SBRxb^2-^Pa(8}Ib#nz~>D%tSz zYSATkUeT|}J1;sEj0$-?@8)-Ef;~@VBhXQO@wwF&MwL}s(os)C>z+x?+F!SI3L4(3 zZcr3#q&{g{I;P9>-sb9incfmUF(;g13}$S~iYzv+%A4Jgg=&CV5C0hNZlyiq*6y5n zy9JqTht&MTN&g&QAb-?&N8VhGyI~R;bl(2{@Q5?xu97Cx3N`*xs@kcj9o4hWB(l)= zWzoqMF?D3s(qZv<+3$q44Fwgn%2~DydR2sx_`Y+2zw|4#}SidrBkc z8(o@}-@clj(Uw0m%ndTXUb0V&1~8eT_PVwrR)}OI0!sW z2x%opUM}j{u_gzp<>mM=)7qY^Zf+vj<()8KiKLRk5Rli~h=Wmn7NxTei7#eHkykbY=S?wsT)q-$Pm zb)M!KxKZ!%rU^;ws_{mle$~_b!Xfmwk2^zO@6%uXNuf-|<$k$JXF!B{WQMrXw7WT@ z(#>N%GyBQ)O%U*~*OB+OZSSSs>F0+5axevLXdZ^_OhXkIx7v%8nm+}-=b!@etv!38+%%S=H z!BY_C#+3Ul{^}m?@i$SQRWmcf9T)WFdwqlOOG*QQ1kzXe0T)zB9K!MbUWu24 zhyX<7VODJugLzJ?4y}a&4|QXX&tY!>BAH`B5YH2=(&Q2$EEa^YIGh^@ZWo0HfU#zH zn^4(wK;2{~78DVGrhIdwU?%+l0t6re0{prF7?0P%0|$U546lQQfyG_G{|g4fSdk2a z;2IG82I3(i#5=hf#f3qU9xtqKdmsT^O!yvye<}=tM3FUpp9_H`k*R%;;USzztiQ)} zNZ=yo`R^tW1dGh%2O^Oua)lo-67Hu1;vtc}{=mayVJuH$jk3tdG4>1w8-QRQdCkWC zbUY4%b-b}Ko@ HW{7_Ppz=lE delta 1072 zcmaE=cT90YKz(e`?mT5fo;P1b*YI=b7JXe4yu@!Rf5d}x>_GmX{|9(APsT03G(oBT z@Nx6nOXi)E+^$V|`7FXBQ~72@kb3B~RVtkPIuG^cev|(u=x|}nL(d;gAvrDsaLr!7}3?#UEXxo32W?;DG2*2?tH5>fS1n#bokK0LH=e)gq= zy@h!Ji?$T%hW%vZ-zAiCG=tgU^W5S#v4qaUyjKf!jeD+LkAFPnmY0yTAM2L+9~?V_ z-F<)Xu3j`}%Y?L&r?(!+t(;oC<>S6v)1Li5I%7`itN?2XXT}dkM=z_}Oqv z+k_X(pK09AaLX4c&(hVM^rb%MOqnUS)RDCu>sYd-os4ttbEHYPWvzL;wKMb73(3{< zjWh1d(mgG?u{)A2!#vi^E8+IskSvc?W!p8HQh9>88ciOv@f_I^nck|yqW)^)4Dn65 zVPW#xZFj?5s?#J(mI<2v48MEVl_kYagkkX&l~R+g#ZrF4_S@{M7jw_s#j0K({*asB z_8H^V9wwp3AH{!G%sqc^>Euin!FodlbD(gLf}Nd$zH3E^dvHl&NveX4 zje@>=v7v$mNRA6AXJ-d91tbJA1*9aTvLF>%n|^Ylf`Og|l4?sN)jp|t=_MHoh6Z2@ z_5Bo#74#>!@tbXa&f?0Y2@(aFYh-3<1hWg|G>}-dg1)D-vr}SmYKns8WPK4+RwEMy zBa_LQB5I6=lP8E|2$(9wXgHN-=AOVq#=!lx&=8 zl5Ck|WMKe=scB|Osb*%zMoE^&26kLF1eGYng5s>WB(bQZq9`?u%hJ)!&T^ E07s5_ssI20 diff --git a/Client/Frontend/Login Management/BreachAlertsDetailView.swift b/Client/Frontend/Login Management/BreachAlertsDetailView.swift index 6eb76d0f79ee..cee36c83b391 100644 --- a/Client/Frontend/Login Management/BreachAlertsDetailView.swift +++ b/Client/Frontend/Login Management/BreachAlertsDetailView.swift @@ -48,8 +48,13 @@ class BreachAlertsDetailView: UIView { lazy var learnMoreButton: UIButton = { let button = UIButton() - button.titleLabel?.font = DynamicFontHelper.defaultHelper.DeviceFontLight - button.setTitle(Strings.BreachAlertsLearnMore, for: .normal) + let attributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: UIColor.white, + .underlineStyle: NSUnderlineStyle.single.rawValue + ] + let attributedText = NSMutableAttributedString(string: Strings.BreachAlertsLearnMore, attributes: attributes) + button.titleLabel?.font = DynamicFontHelper.defaultHelper.DeviceFontSmallBold + button.setAttributedTitle(attributedText, for: .normal) button.setTitleColor(textColor, for: .normal) button.tintColor = .white button.isAccessibilityElement = true @@ -102,8 +107,9 @@ class BreachAlertsDetailView: UIView { private lazy var infoStack: UIStackView = { let stack = UIStackView(arrangedSubviews: [breachDateLabel, descriptionLabel, goToButton]) - stack.distribution = .fillProportionally + stack.distribution = .fill stack.axis = .vertical + stack.setCustomSpacing(8.0, after: descriptionLabel) return stack }() @@ -116,7 +122,7 @@ class BreachAlertsDetailView: UIView { override init(frame: CGRect) { super.init(frame: frame) - self.backgroundColor = BreachAlertsManager.detailColor + self.backgroundColor = BreachAlertsManager.lightMode self.layer.cornerRadius = 5 self.layer.masksToBounds = true diff --git a/Client/Frontend/Login Management/BreachAlertsManager.swift b/Client/Frontend/Login Management/BreachAlertsManager.swift index 961cb00ecd07..16bde7c9b249 100644 --- a/Client/Frontend/Login Management/BreachAlertsManager.swift +++ b/Client/Frontend/Login Management/BreachAlertsManager.swift @@ -26,8 +26,8 @@ struct BreachRecord: Codable, Equatable, Hashable { /// A manager for the user's breached login information, if any. final public class BreachAlertsManager { static let icon = UIImage(named: "Breached Website")?.withRenderingMode(.alwaysTemplate) - static let listColor = UIColor(red: 0.78, green: 0.16, blue: 0.18, alpha: 1.00) - static let detailColor = UIColor(red: 0.59, green: 0.11, blue: 0.11, alpha: 1.00) + static let lightMode = UIColor(red: 0.77, green: 0.00, blue: 0.26, alpha: 1.00) + static let darkMode = UIColor(red: 1.00, green: 0.02, blue: 0.35, alpha: 1.00) static let monitorAboutUrl = URL(string: "https://monitor.firefox.com/about") var breaches = Set() var breachAlertsClient: BreachAlertsClientProtocol diff --git a/Client/Frontend/Login Management/LoginDataSource.swift b/Client/Frontend/Login Management/LoginDataSource.swift index 5af9a19e0201..5cd8a45720f5 100644 --- a/Client/Frontend/Login Management/LoginDataSource.swift +++ b/Client/Frontend/Login Management/LoginDataSource.swift @@ -68,6 +68,11 @@ class LoginDataSource: NSObject, UITableViewDataSource { cell.hostnameLabel.text = login.hostname cell.usernameLabel.textColor = UIColor.theme.tableView.rowDetailText cell.usernameLabel.text = login.username != "" ? login.username : "(no username)" + if NightModeHelper.hasEnabledDarkTheme(viewModel.profile.prefs) { + cell.breachAlertImageView.tintColor = BreachAlertsManager.darkMode + } else { + cell.breachAlertImageView.tintColor = BreachAlertsManager.lightMode + } if let breaches = viewModel.userBreaches, breaches.contains(login) { cell.breachAlertImageView.isHidden = false } diff --git a/Client/Frontend/Login Management/LoginListTableViewCell.swift b/Client/Frontend/Login Management/LoginListTableViewCell.swift index 7ac8c6cbd2a6..13736653594a 100644 --- a/Client/Frontend/Login Management/LoginListTableViewCell.swift +++ b/Client/Frontend/Login Management/LoginListTableViewCell.swift @@ -18,7 +18,6 @@ class LoginListTableViewCell: ThemedTableViewCell { private let breachAlertSize: CGFloat = 24 lazy var breachAlertImageView: UIImageView = { let imageView = UIImageView(image: BreachAlertsManager.icon) - imageView.tintColor = BreachAlertsManager.listColor imageView.isHidden = true return imageView }() From ab66183d314b9a4accb01f5cb793d22472b25fa7 Mon Sep 17 00:00:00 2001 From: Vanna Phong Date: Fri, 14 Aug 2020 10:06:39 -0700 Subject: [PATCH 13/14] =?UTF-8?q?FXIOS-731=20=E2=81=83=20HTTP=20HEAD=20eta?= =?UTF-8?q?gs=20to=20cut=20down=20on=20data=20requests=20from=20Breach=20A?= =?UTF-8?q?lerts=20(#7100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * first pass * etag & last accessed date integration * test compatibility * better completion handlingg * cleanup * review 1 * review 2 * Update BreachAlertsManager.swift * formatting/test reformation * fix logic errors * Update BreachAlertsManager.swift * fix logic * remove mock data --- .../Login Management/BreachAlertsClient.swift | 65 +++++++-- .../BreachAlertsManager.swift | 134 ++++++++++++------ .../Login Management/LoginListViewModel.swift | 12 +- .../Settings/LoginDetailViewController.swift | 5 +- ClientTests/BreachAlertsTests.swift | 12 +- ClientTests/LoginsListViewModelTests.swift | 8 +- 6 files changed, 167 insertions(+), 69 deletions(-) diff --git a/Client/Frontend/Login Management/BreachAlertsClient.swift b/Client/Frontend/Login Management/BreachAlertsClient.swift index 66b116a016da..9dd7b8fddba6 100644 --- a/Client/Frontend/Login Management/BreachAlertsClient.swift +++ b/Client/Frontend/Login Management/BreachAlertsClient.swift @@ -11,7 +11,8 @@ struct BreachAlertsError: MaybeErrorType { } /// For mocking and testing BreachAlertsClient. protocol BreachAlertsClientProtocol { - func fetchData(endpoint: BreachAlertsClient.Endpoint, completion: @escaping (_ result: Maybe) -> Void) + func fetchEtag(endpoint: BreachAlertsClient.Endpoint, profile: Profile, completion: @escaping (_ etag: String?) -> Void) + func fetchData(endpoint: BreachAlertsClient.Endpoint, profile: Profile, completion: @escaping (_ result: Maybe) -> Void) } /// Handles all network requests for BreachAlertsManager. @@ -20,28 +21,70 @@ public class BreachAlertsClient: BreachAlertsClientProtocol { public enum Endpoint: String { case breachedAccounts = "https://monitor.firefox.com/hibp/breaches" } + static let etagKey = "BreachAlertsDataEtag" + static let etagDateKey = "BreachAlertsDataDate" - /// Makes a network request to an endpoint and hands off the result to a completion handler. - public func fetchData(endpoint: Endpoint, completion: @escaping (_ result: Maybe) -> Void) { - guard let url = URL(string: endpoint.rawValue) else { - completion(Maybe(failure: BreachAlertsError(description: "bad endpoint URL"))) - return + /// Makes a header-only request to an endpoint and hands off the endpoint's etag to a completion handler. + func fetchEtag(endpoint: Endpoint, profile: Profile, completion: @escaping (_ etag: String?) -> Void) { + guard let url = URL(string: endpoint.rawValue) else { return } + var request = URLRequest(url: url) + request.httpMethod = "HEAD" + + dataTask?.cancel() + dataTask = URLSession.shared.dataTask(with: request) { _, response, _ in + guard let response = response as? HTTPURLResponse else { return } + guard response.statusCode < 400 else { + Sentry.shared.send(message: "BreachAlerts: fetchEtag: HTTP status code: \(response.statusCode)") + completion(nil) + return + } + guard let etag = response.allHeaderFields["Etag"] as Any as? String else { + completion(nil) + assert(false) + return + } + DispatchQueue.main.async { + completion(etag) + } } + dataTask?.resume() + } + + /// Makes a network request to an endpoint and hands off the result to a completion handler. + func fetchData(endpoint: Endpoint, profile: Profile, completion: @escaping (_ result: Maybe) -> Void) { + guard let url = URL(string: endpoint.rawValue) else { return } + dataTask?.cancel() dataTask = URLSession.shared.dataTask(with: url) { data, response, error in - guard validatedHTTPResponse(response) != nil else { - completion(Maybe(failure: BreachAlertsError(description: "invalid HTTP response"))) + guard let response = response as? HTTPURLResponse else { return } + guard response.statusCode < 400 else { + Sentry.shared.send(message: "BreachAlerts: fetchData: HTTP status code: \(response.statusCode)") return } if let error = error { completion(Maybe(failure: BreachAlertsError(description: error.localizedDescription))) + Sentry.shared.send(message: "BreachAlerts: fetchData: \(error)") return } - guard let data = data, !data.isEmpty else { - completion(Maybe(failure: BreachAlertsError(description: "empty data"))) + guard let data = data else { + completion(Maybe(failure: BreachAlertsError(description: "invalid data"))) + Sentry.shared.send(message: "BreachAlerts: fetchData: invalid data") + assert(false) return } - completion(Maybe(success: data)) + + guard let etag = response.allHeaderFields["Etag"] as Any as? String else { return } + let date = Date.now() + + if profile.prefs.stringForKey(BreachAlertsClient.etagKey) != etag { + profile.prefs.setString(etag, forKey: BreachAlertsClient.etagKey) + } + if profile.prefs.timestampForKey(BreachAlertsClient.etagDateKey) != date { + profile.prefs.setTimestamp(date, forKey: BreachAlertsClient.etagDateKey) + } + DispatchQueue.main.async { + completion(Maybe(success: data)) + } } dataTask?.resume() } diff --git a/Client/Frontend/Login Management/BreachAlertsManager.swift b/Client/Frontend/Login Management/BreachAlertsManager.swift index 16bde7c9b249..1f43fb15503a 100644 --- a/Client/Frontend/Login Management/BreachAlertsManager.swift +++ b/Client/Frontend/Login Management/BreachAlertsManager.swift @@ -30,50 +30,77 @@ final public class BreachAlertsManager { static let darkMode = UIColor(red: 1.00, green: 0.02, blue: 0.35, alpha: 1.00) static let monitorAboutUrl = URL(string: "https://monitor.firefox.com/about") var breaches = Set() - var breachAlertsClient: BreachAlertsClientProtocol - - init(_ client: BreachAlertsClientProtocol = BreachAlertsClient()) { - self.breachAlertsClient = client + var client: BreachAlertsClientProtocol + var profile: Profile! + private lazy var cacheURL: URL? = { + guard let path = try? self.profile.files.getAndEnsureDirectory() else { + return nil + } + return URL(fileURLWithPath: path, isDirectory: true).appendingPathComponent("breaches.json") + }() + private let dateFormatter = DateFormatter() + init(_ client: BreachAlertsClientProtocol = BreachAlertsClient(), profile: Profile) { + self.client = client + self.profile = profile } /// Loads breaches from Monitor endpoint using BreachAlertsClient. /// - Parameters: /// - completion: a completion handler for the processed breaches func loadBreaches(completion: @escaping (Maybe>) -> Void) { - self.breachAlertsClient.fetchData(endpoint: .breachedAccounts) { maybeData in - guard let data = maybeData.successValue else { - completion(Maybe(failure: BreachAlertsError(description: "failed to load breaches data"))) - return - } - guard let decoded = try? JSONDecoder().decode(Set.self, from: data) else { - completion(Maybe(failure: BreachAlertsError(description: "JSON data decode failure"))) + guard let cacheURL = self.cacheURL else { + self.fetchAndSaveBreaches(completion) + return + } + + // 1. check for local breaches file + guard FileManager.default.fileExists(atPath: cacheURL.path) else { + // 1a. no local file, so fetch and save as normal and hand off + self.fetchAndSaveBreaches(completion) + return + } + + // 1b. local file exists, so load from that + guard let fileData = FileManager.default.contents(atPath: cacheURL.path) else { + completion(Maybe(failure: BreachAlertsError(description: "failed to get data from breach.json"))) + Sentry.shared.send(message: "BreachAlerts: failed to get data from breach.json") + try? FileManager.default.removeItem(at: cacheURL) // bad file, so delete it + self.fetchAndSaveBreaches(completion) + return + } + + // 2. check the last time breach endpoint was accessed + guard let dateLastAccessed = profile.prefs.timestampForKey(BreachAlertsClient.etagDateKey) else { + profile.prefs.removeObjectForKey(BreachAlertsClient.etagDateKey) // bad key, so delete it + self.fetchAndSaveBreaches(completion) + return + } + let timeUntilNextUpdate = UInt64(60 * 60 * 24 * 3 * 1000) // 3 days in milliseconds + let shouldUpdateDate = dateLastAccessed + timeUntilNextUpdate + + // 3. if 3 days have not passed since last update... + guard Date.now() >= shouldUpdateDate else { + // 3a. no need to refetch. decode local data and hand off + decodeData(data: fileData, completion) + return + } + + // 3b. should update - check if the etag is different + client.fetchEtag(endpoint: .breachedAccounts, profile: self.profile) { etag in + guard let etag = etag else { + self.profile.prefs.removeObjectForKey(BreachAlertsClient.etagKey) // bad key, so delete it + self.fetchAndSaveBreaches(completion) return } + let savedEtag = self.profile.prefs.stringForKey(BreachAlertsClient.etagKey) - self.breaches = decoded - // remove for release - self.breaches.insert(BreachRecord( - name: "MockBreach", - title: "A Mock Blockbuster Record", - domain: "blockbuster.com", - breachDate: "1970-01-02", - description: "A mock BreachRecord for testing purposes." - )) - self.breaches.insert(BreachRecord( - name: "MockBreach", - title: "A Mock Lorem Ipsum Record", - domain: "lipsum.com", - breachDate: "1970-01-02", - description: "A mock BreachRecord for testing purposes." - )) - self.breaches.insert(BreachRecord( - name: "MockBreach", - title: "A Mock Swift Breach Record", - domain: "swift.org", - breachDate: "1970-01-02", - description: "A mock BreachRecord for testing purposes." - )) - completion(Maybe(success: self.breaches)) + // 4. if it is, refetch the data and hand entire Set of BreachRecords off + if etag != savedEtag { + self.fetchAndSaveBreaches(completion) + } else { + self.profile.prefs.setTimestamp(Date.now(), forKey: BreachAlertsClient.etagDateKey) + self.decodeData(data: fileData, completion) + } } } @@ -98,9 +125,8 @@ final public class BreachAlertsManager { } for item in potentialUserBreaches { let pwLastChanged = TimeInterval(item.timePasswordChanged/1000) - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd" - guard let breachDate = dateFormatter.date(from: breach.breachDate)?.timeIntervalSince1970, pwLastChanged < breachDate else { + self.dateFormatter.dateFormat = "yyyy-MM-dd" + guard let breachDate = self.dateFormatter.date(from: breach.breachDate)?.timeIntervalSince1970, pwLastChanged < breachDate else { continue } result.insert(item) @@ -136,9 +162,8 @@ final public class BreachAlertsManager { let baseDomain = self.baseDomainForLogin(login) for breach in self.breaches where breach.domain == baseDomain { let pwLastChanged = TimeInterval(login.timePasswordChanged/1000) - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd" - guard let breachDate = dateFormatter.date(from: breach.breachDate)?.timeIntervalSince1970, pwLastChanged < breachDate else { + self.dateFormatter.dateFormat = "yyyy-MM-dd" + guard let breachDate = self.dateFormatter.date(from: breach.breachDate)?.timeIntervalSince1970, pwLastChanged < breachDate else { continue } return breach @@ -146,8 +171,35 @@ final public class BreachAlertsManager { return nil } + // MARK: - Helper Functions private func baseDomainForLogin(_ login: LoginRecord) -> String { guard let result = login.hostname.asURL?.baseDomain else { return login.hostname } return result } + + private func fetchAndSaveBreaches(_ completion: @escaping (Maybe>) -> Void) { + guard let cacheURL = self.cacheURL else { + return + } + self.client.fetchData(endpoint: .breachedAccounts, profile: self.profile) { maybeData in + guard let fetchedData = maybeData.successValue else { return } + try? FileManager.default.removeItem(atPath: cacheURL.path) + FileManager.default.createFile(atPath: cacheURL.path, contents: fetchedData, attributes: nil) + + guard let data = FileManager.default.contents(atPath: cacheURL.path) else { return } + self.decodeData(data: data, completion) + } + } + + private func decodeData(data: Data, _ completion: @escaping (Maybe>) -> Void) { + guard let decoded = try? JSONDecoder().decode(Set.self, from: data) else { + print(BreachAlertsError(description: "JSON data decode failure")) + assert(false) + return + } + + self.breaches = decoded + + completion(Maybe(success: self.breaches)) + } } diff --git a/Client/Frontend/Login Management/LoginListViewModel.swift b/Client/Frontend/Login Management/LoginListViewModel.swift index 36ccffc1d390..0cb256d0b6c4 100644 --- a/Client/Frontend/Login Management/LoginListViewModel.swift +++ b/Client/Frontend/Login Management/LoginListViewModel.swift @@ -24,7 +24,9 @@ final class LoginListViewModel { } } fileprivate let helper = LoginListDataSourceHelper() - private(set) var breachAlertsManager = BreachAlertsManager() + private(set) lazy var breachAlertsManager: BreachAlertsManager = { + return BreachAlertsManager(profile: self.profile) + }() private(set) var userBreaches: Set? private(set) var breachIndexPath = Set() { didSet { @@ -61,11 +63,7 @@ final class LoginListViewModel { func queryLogins(_ query: String) -> Deferred> { let deferred = Deferred>() profile.logins.searchLoginsWithQuery(query) >>== { logins in - var log = logins.asArray() - log.append(LoginRecord(fromJSONDict: ["hostname" : "https://blockbuster.com", "timePasswordChanged": 46800000, "password": "ipsum"])) - log.append(LoginRecord(fromJSONDict: ["hostname" : "https://lipsum.com", "timePasswordChanged": 46800000, "username": "lorem", "password": "ipsum"])) - log.append(LoginRecord(fromJSONDict: ["hostname" : "https://swift.org", "timePasswordChanged": 46800000, "username": "username", "password": "ipsum"])) - deferred.fillIfUnfilled(Maybe(success: log)) + deferred.fillIfUnfilled(Maybe(success: logins.asArray())) succeed() } return deferred @@ -137,7 +135,7 @@ final class LoginListViewModel { } func setBreachAlertsManager(_ client: BreachAlertsClientProtocol) { - self.breachAlertsManager = BreachAlertsManager(client) + self.breachAlertsManager = BreachAlertsManager(client, profile: profile) } // MARK: - UX Constants diff --git a/Client/Frontend/Settings/LoginDetailViewController.swift b/Client/Frontend/Settings/LoginDetailViewController.swift index c71eec44d197..162f4a5b4269 100644 --- a/Client/Frontend/Settings/LoginDetailViewController.swift +++ b/Client/Frontend/Settings/LoginDetailViewController.swift @@ -149,8 +149,9 @@ extension LoginDetailViewController: UITableViewDataSource { } breachDetailView.setup(breach) - breachDetailView.learnMoreButton.addTarget(self, action: #selector(didTapBreachLearnMore), for: .touchUpInside) - let breachLinkGesture = UITapGestureRecognizer(target: self, action: #selector(didTapBreachLink(_:))) + breachDetailView.learnMoreButton.addTarget(self, action: #selector(LoginDetailViewController.didTapBreachLearnMore), for: .touchUpInside) + let breachLinkGesture = UITapGestureRecognizer(target: self, action: #selector(LoginDetailViewController + .didTapBreachLink(_:))) breachDetailView.goToButton.addGestureRecognizer(breachLinkGesture) breachCell.isAccessibilityElement = false breachCell.contentView.accessibilityElementsHidden = true diff --git a/ClientTests/BreachAlertsTests.swift b/ClientTests/BreachAlertsTests.swift index ea876c75ec72..911f46020c64 100644 --- a/ClientTests/BreachAlertsTests.swift +++ b/ClientTests/BreachAlertsTests.swift @@ -14,7 +14,6 @@ let blockbusterBreach = BreachRecord( breachDate: "1970-01-02", description: "A mock BreachRecord for testing purposes." ) -// remove for official release let lipsumBreach = BreachRecord( name: "MockBreach", title: "A Mock Lorem Ipsum Record", @@ -31,14 +30,19 @@ let longBreach = BreachRecord( ) let unbreachedLogin = LoginRecord(fromJSONDict: ["hostname" : "http://unbreached.com", "timePasswordChanged": 1594411049000]) let breachedLogin = LoginRecord(fromJSONDict: ["hostname" : "http://blockbuster.com", "timePasswordChanged": 46800000]) + class MockBreachAlertsClient: BreachAlertsClientProtocol { - func fetchData(endpoint: BreachAlertsClient.Endpoint, completion: @escaping (Maybe) -> Void) { - guard let mockData = try? JSONEncoder().encode([blockbusterBreach].self) else { + func fetchEtag(endpoint: BreachAlertsClient.Endpoint, profile: Client.Profile, completion: @escaping (String?) -> Void) { + completion("33a64df551425fcc55e4d42a148795d9f25f89d4") + } + func fetchData(endpoint: BreachAlertsClient.Endpoint, profile: Client.Profile, completion: @escaping (Maybe) -> Void) { + guard let mockData = try? JSONEncoder().encode([blockbusterBreach, longBreach, lipsumBreach].self) else { completion(Maybe(failure: BreachAlertsError(description: "failed to encode mockRecord"))) return } completion(Maybe(success: mockData)) } + var etag: String? = nil } class BreachAlertsTests: XCTestCase { @@ -47,7 +51,7 @@ class BreachAlertsTests: XCTestCase { let breachedLoginSet = Set(arrayLiteral: breachedLogin) override func setUp() { - self.breachAlertsManager = BreachAlertsManager(MockBreachAlertsClient()) + self.breachAlertsManager = BreachAlertsManager(MockBreachAlertsClient(), profile: MockProfile()) } func testDataRequest() { diff --git a/ClientTests/LoginsListViewModelTests.swift b/ClientTests/LoginsListViewModelTests.swift index db0a68f5e7e8..a9c0b2dfa860 100644 --- a/ClientTests/LoginsListViewModelTests.swift +++ b/ClientTests/LoginsListViewModelTests.swift @@ -45,19 +45,19 @@ class LoginsListViewModelTests: XCTestCase { func testQueryLogins() { let emptyQueryResult = self.viewModel.queryLogins("") XCTAssertTrue(emptyQueryResult.value.isSuccess) - XCTAssertEqual(emptyQueryResult.value.successValue?.count, 13) + XCTAssertEqual(emptyQueryResult.value.successValue?.count, 10) let exampleQueryResult = self.viewModel.queryLogins("example") XCTAssertTrue(exampleQueryResult.value.isSuccess) - XCTAssertEqual(exampleQueryResult.value.successValue?.count, 13) + XCTAssertEqual(exampleQueryResult.value.successValue?.count, 10) let threeQueryResult = self.viewModel.queryLogins("3") XCTAssertTrue(threeQueryResult.value.isSuccess) - XCTAssertEqual(threeQueryResult.value.successValue?.count, 4) + XCTAssertEqual(threeQueryResult.value.successValue?.count, 1) let zQueryResult = self.viewModel.queryLogins("yxz") XCTAssertTrue(zQueryResult.value.isSuccess) - XCTAssertEqual(zQueryResult.value.successValue?.count, 3) + XCTAssertEqual(zQueryResult.value.successValue?.count, 0) } func testIsDuringSearchControllerDismiss() { From e27ce2312e2e570d06ab3b2af3976b99b8f898dd Mon Sep 17 00:00:00 2001 From: Vanna Phong Date: Fri, 14 Aug 2020 12:01:24 -0700 Subject: [PATCH 14/14] Lint --- Client/Frontend/Login Management/LoginDataSource.swift | 2 +- Client/Frontend/Settings/AppSettingsOptions.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Client/Frontend/Login Management/LoginDataSource.swift b/Client/Frontend/Login Management/LoginDataSource.swift index 5cd8a45720f5..e622915b5a04 100644 --- a/Client/Frontend/Login Management/LoginDataSource.swift +++ b/Client/Frontend/Login Management/LoginDataSource.swift @@ -67,7 +67,7 @@ class LoginDataSource: NSObject, UITableViewDataSource { guard let login = viewModel.loginAtIndexPath(indexPath) else { return cell } cell.hostnameLabel.text = login.hostname cell.usernameLabel.textColor = UIColor.theme.tableView.rowDetailText - cell.usernameLabel.text = login.username != "" ? login.username : "(no username)" + cell.usernameLabel.text = login.username.isEmpty ? "(no username)" : login.username if NightModeHelper.hasEnabledDarkTheme(viewModel.profile.prefs) { cell.breachAlertImageView.tintColor = BreachAlertsManager.darkMode } else { diff --git a/Client/Frontend/Settings/AppSettingsOptions.swift b/Client/Frontend/Settings/AppSettingsOptions.swift index e5e32e3b63dd..15266fdd101a 100644 --- a/Client/Frontend/Settings/AppSettingsOptions.swift +++ b/Client/Frontend/Settings/AppSettingsOptions.swift @@ -607,7 +607,7 @@ class VersionSetting: Setting { } override var title: NSAttributedString? { - return NSAttributedString(string: String(format: NSLocalizedString("Version %@ (%@)", comment: "Version number of Firefox shown in settings"), VersionSetting.appVersion, VersionSetting.appBuildNumber), attributes: [NSAttributedString.Key.foregroundColor: UIColor.theme.tableView.rowText]) + return NSAttributedString(string: String(format: NSLocalizedString("Version %@ (%@)", comment: "Version number of Firefox shown in settings"), VersionSetting.appVersion, VersionSetting.appBuildNumber), attributes: [NSAttributedString.Key.foregroundColor: UIColor.theme.tableView.rowText]) } public static var appVersion: String {