diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e145b145a3..bdbdb037cc 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -128,6 +128,9 @@ jobs: name: Make Release Build + # Dependabot doesn't have access to all secrets, so we skip this job + if: github.actor != 'dependabot[bot]' + runs-on: macos-13-xlarge timeout-minutes: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd02518132..49b135d79f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,15 +20,13 @@ on: branches: - release/** - hotfix/** - - coldfix/** - '!release/**-' # filter out PRs matching that pattern - '!hotfix/**-' - - '!coldfix/**-' types: [closed] jobs: make-release: - if: github.event.action == 0 || github.event.pull_request.merged == true # empty string returns 0; for case when workflow is triggered manually + if: github.event.action == 0 || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'Merge triggers release')) # empty string returns 0; for case when workflow is triggered manually runs-on: macos-13-xlarge name: Make App Store Connect Release @@ -98,7 +96,7 @@ jobs: - name: Upload debug symbols to Asana if: ${{ always() && github.event.inputs.asana-task-url }} - env: + env: ASANA_ACCESS_TOKEN: ${{ secrets.ASANA_ACCESS_TOKEN }} run: | if [[ -f ${{ env.dsyms_path }} ]]; then diff --git a/.gitignore b/.gitignore index 07ebc5f934..d460333497 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,5 @@ fastlane/test_output Configuration/ExternalDeveloper.xcconfig scripts/assets + +DuckDuckGoTests/NetworkProtectionVPNLocationViewModelTests.swift*.plist diff --git a/.maestro/release_tests/emailprotection.yaml b/.maestro/release_tests/emailprotection.yaml index e4cc61fba4..4b2526dbbf 100644 --- a/.maestro/release_tests/emailprotection.yaml +++ b/.maestro/release_tests/emailprotection.yaml @@ -18,7 +18,9 @@ tags: - scroll - assertVisible: Email Protection - tapOn: Email Protection +- assertVisible: Email privacy, simplified. - assertVisible: id: searchEntry +- tapOn: + id: "searchEntry" - assertVisible: https://duckduckgo.com/email/ -- assertVisible: Email privacy, simplified. \ No newline at end of file diff --git a/Configuration/Version.xcconfig b/Configuration/Version.xcconfig index cccf98adf5..d31068acdf 100644 --- a/Configuration/Version.xcconfig +++ b/Configuration/Version.xcconfig @@ -1 +1 @@ -MARKETING_VERSION = 7.106.0 +MARKETING_VERSION = 7.107.0 diff --git a/Core/AppPrivacyConfigurationDataProvider.swift b/Core/AppPrivacyConfigurationDataProvider.swift index 4f4eb843c8..e9277bd629 100644 --- a/Core/AppPrivacyConfigurationDataProvider.swift +++ b/Core/AppPrivacyConfigurationDataProvider.swift @@ -23,8 +23,8 @@ import BrowserServicesKit final public class AppPrivacyConfigurationDataProvider: EmbeddedDataProvider { public struct Constants { - public static let embeddedDataETag = "\"4e984b6034f1e27fe85fdad5f4bf37c9\"" - public static let embeddedDataSHA = "d599888e7b447bbaeb2d9a7fd7ccf06956fce8976c316be2f497561a6832613e" + public static let embeddedDataETag = "\"d0ae514c42e1e632584aba7a025b8b92\"" + public static let embeddedDataSHA = "b304a2dbb2edc7443a4950bb2ba9f7604354cf32575dd5a9ca09acd5c4b78146" } public var embeddedDataEtag: String { diff --git a/Core/CookieStorage.swift b/Core/CookieStorage.swift index 03d6426a22..5624827ee1 100644 --- a/Core/CookieStorage.swift +++ b/Core/CookieStorage.swift @@ -22,16 +22,27 @@ import Foundation public class CookieStorage { - struct Constants { - static let key = "com.duckduckgo.allowedCookies" + struct Keys { + static let allowedCookies = "com.duckduckgo.allowedCookies" + static let consumed = "com.duckduckgo.consumedCookies" } private var userDefaults: UserDefaults - + + var isConsumed: Bool { + get { + userDefaults.bool(forKey: Keys.consumed, defaultValue: false) + } + set { + userDefaults.set(newValue, forKey: Keys.consumed) + } + } + + /// Use the `updateCookies` function rather than the setter which is only visible for testing. var cookies: [HTTPCookie] { get { var storedCookies = [HTTPCookie]() - if let cookies = userDefaults.object(forKey: Constants.key) as? [[String: Any?]] { + if let cookies = userDefaults.object(forKey: Keys.allowedCookies) as? [[String: Any?]] { for cookieData in cookies { var properties = [HTTPCookiePropertyKey: Any]() cookieData.forEach({ @@ -57,17 +68,76 @@ public class CookieStorage { } cookies.append(mappedCookie) } - userDefaults.setValue(cookies, forKey: Constants.key) + userDefaults.setValue(cookies, forKey: Keys.allowedCookies) } + } public init(userDefaults: UserDefaults = UserDefaults.app) { self.userDefaults = userDefaults } - func clear() { - userDefaults.removeObject(forKey: Constants.key) - os_log("cleared cookies", log: .generalLog, type: .debug) + enum CookieDomainsOnUpdate { + case empty + case match + case missing + case different } + + @discardableResult + func updateCookies(_ cookies: [HTTPCookie], keepingPreservedLogins preservedLogins: PreserveLogins) -> CookieDomainsOnUpdate { + isConsumed = false + + let persisted = self.cookies + + func cookiesByDomain(_ cookies: [HTTPCookie]) -> [String: [HTTPCookie]] { + var byDomain = [String: [HTTPCookie]]() + cookies.forEach { cookie in + var cookies = byDomain[cookie.domain, default: []] + cookies.append(cookie) + byDomain[cookie.domain] = cookies + } + return byDomain + } + + let updatedCookiesByDomain = cookiesByDomain(cookies) + var persistedCookiesByDomain = cookiesByDomain(persisted) + // Do the diagnostics before the dicts get changed. + let diagnosticResult = evaluateDomains( + updatedDomains: updatedCookiesByDomain.keys.sorted(), + persistedDomains: persistedCookiesByDomain.keys.sorted() + ) + + updatedCookiesByDomain.keys.forEach { + persistedCookiesByDomain[$0] = updatedCookiesByDomain[$0] + } + + persistedCookiesByDomain.keys.forEach { + guard $0 != "duckduckgo.com" else { return } // DDG cookies are for SERP settings only + + if !preservedLogins.isAllowed(cookieDomain: $0) { + persistedCookiesByDomain.removeValue(forKey: $0) + } + } + + let now = Date() + self.cookies = persistedCookiesByDomain.map { $0.value }.joined().compactMap { $0 } + .filter { $0.expiresDate == nil || $0.expiresDate! > now } + + return diagnosticResult + } + + private func evaluateDomains(updatedDomains: [String], persistedDomains: [String]) -> CookieDomainsOnUpdate { + if persistedDomains.isEmpty { + return .empty + } else if updatedDomains.count < persistedDomains.count { + return .missing + } else if updatedDomains == persistedDomains { + return .match + } else { + return .different + } + } + } diff --git a/Core/DailyPixel.swift b/Core/DailyPixel.swift index e0482ede40..57e3bf60f6 100644 --- a/Core/DailyPixel.swift +++ b/Core/DailyPixel.swift @@ -71,10 +71,16 @@ public final class DailyPixel { public static func fireDailyAndCount(pixel: Pixel.Event, error: Swift.Error? = nil, withAdditionalParameters params: [String: String] = [:], + includedParameters: [Pixel.QueryParameters] = [.atb, .appVersion], onDailyComplete: @escaping (Swift.Error?) -> Void = { _ in }, onCountComplete: @escaping (Swift.Error?) -> Void = { _ in }) { if !pixel.hasBeenFiredToday(dailyPixelStorage: storage) { - Pixel.fire(pixelNamed: pixel.name + "_d", withAdditionalParameters: params, onComplete: onDailyComplete) + Pixel.fire( + pixelNamed: pixel.name + "_d", + withAdditionalParameters: params, + includedParameters: includedParameters, + onComplete: onDailyComplete + ) } else { onDailyComplete(Error.alreadyFired) } @@ -83,7 +89,12 @@ public final class DailyPixel { if let error { newParams.appendErrorPixelParams(error: error) } - Pixel.fire(pixelNamed: pixel.name + "_c", withAdditionalParameters: newParams, onComplete: onCountComplete) + Pixel.fire( + pixelNamed: pixel.name + "_c", + withAdditionalParameters: newParams, + includedParameters: includedParameters, + onComplete: onCountComplete + ) } private static func updatePixelLastFireDate(pixel: Pixel.Event) { diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index 8e449ec840..babb0b9179 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -312,6 +312,7 @@ extension Pixel { case networkProtectionKeychainErrorFailedToCastKeychainValueToData case networkProtectionKeychainReadError case networkProtectionKeychainWriteError + case networkProtectionKeychainUpdateError case networkProtectionKeychainDeleteError case networkProtectionWireguardErrorCannotLocateTunnelFileDescriptor @@ -341,6 +342,7 @@ extension Pixel { case networkProtectionWaitlistTermsAccepted case networkProtectionWaitlistNotificationShown case networkProtectionWaitlistNotificationLaunched + case networkProtectionWaitlistRetriedInviteCodeRedemption case networkProtectionGeoswitchingOpened case networkProtectionGeoswitchingSetNearest @@ -521,6 +523,8 @@ extension Pixel { case emailIncontextModalExitEarlyContinue case compilationFailed + + case appRatingPromptFetchError } } @@ -810,6 +814,7 @@ extension Pixel.Event { case .networkProtectionKeychainErrorFailedToCastKeychainValueToData: return "m_netp_keychain_error_failed_to_cast_keychain_value_to_data" case .networkProtectionKeychainReadError: return "m_netp_keychain_error_read_failed" case .networkProtectionKeychainWriteError: return "m_netp_keychain_error_write_failed" + case .networkProtectionKeychainUpdateError: return "m_netp_keychain_error_update_failed" case .networkProtectionKeychainDeleteError: return "m_netp_keychain_error_delete_failed" case .networkProtectionWireguardErrorCannotLocateTunnelFileDescriptor: return "m_netp_wireguard_error_cannot_locate_tunnel_file_descriptor" case .networkProtectionWireguardErrorInvalidState: return "m_netp_wireguard_error_invalid_state" @@ -833,6 +838,7 @@ extension Pixel.Event { case .networkProtectionWaitlistTermsAccepted: return "m_netp_waitlist_terms_accepted" case .networkProtectionWaitlistNotificationShown: return "m_netp_waitlist_notification_shown" case .networkProtectionWaitlistNotificationLaunched: return "m_netp_waitlist_notification_launched" + case .networkProtectionWaitlistRetriedInviteCodeRedemption: return "m_netp_waitlist_retried_invite_code_redemption" case .networkProtectionGeoswitchingOpened: return "m_netp_imp_geoswitching" case .networkProtectionGeoswitchingSetNearest: return "m_netp_ev_geoswitching_set_nearest" @@ -1015,6 +1021,8 @@ extension Pixel.Event { // MARK: - Return user measurement case .debugReturnUserAddATB: return "m_debug_return_user_add_atb" case .debugReturnUserUpdateATB: return "m_debug_return_user_update_atb" + + case .appRatingPromptFetchError: return "m_d_app_rating_prompt_fetch_error" } } diff --git a/Core/WebCacheManager.swift b/Core/WebCacheManager.swift index bafedec89b..2c66cdd06b 100644 --- a/Core/WebCacheManager.swift +++ b/Core/WebCacheManager.swift @@ -74,7 +74,7 @@ public class WebCacheManager { let cookies = cookieStorage.cookies - guard !cookies.isEmpty else { + guard !cookies.isEmpty, !cookieStorage.isConsumed else { completion() return } @@ -93,9 +93,9 @@ public class WebCacheManager { DispatchQueue.global(qos: .userInitiated).async { group.wait() + cookieStorage.isConsumed = true DispatchQueue.main.async { - cookieStorage.clear() completion() if cookieStorage.cookies.count > 0 { @@ -162,7 +162,7 @@ public class WebCacheManager { logins: PreserveLogins, storeIdManager: DataStoreIdManager, completion: @escaping () -> Void) { - + guard let containerId = storeIdManager.id else { completion() return @@ -181,11 +181,8 @@ public class WebCacheManager { await checkForLeftBehindDataStores() storeIdManager.allocateNewContainerId() - // If cookies is empty it's likely that the webview was not used since the last fire button so - // don't overwrite previously saved cookies - if let cookies, !cookies.isEmpty { - cookieStorage.cookies = cookies - } + + cookieStorage.updateCookies(cookies ?? [], keepingPreservedLogins: logins) completion() } @@ -208,7 +205,7 @@ public class WebCacheManager { // From this point onwards... use containers dataStoreIdManager.allocateNewContainerId() Task { @MainActor in - cookieStorage.cookies = cookies + cookieStorage.updateCookies(cookies, keepingPreservedLogins: logins) completion() } } else { diff --git a/Core/ios-config.json b/Core/ios-config.json index d884041033..311a1e6e36 100644 --- a/Core/ios-config.json +++ b/Core/ios-config.json @@ -1,6 +1,6 @@ { "readme": "https://github.com/duckduckgo/privacy-configuration", - "version": 1705931475791, + "version": 1706638025243, "features": { "adClickAttribution": { "readme": "https://help.duckduckgo.com/duckduckgo-help-pages/privacy/web-tracking-protections/#3rd-party-tracker-loading-protection", @@ -4228,6 +4228,9 @@ { "domain": "tirerack.com" }, + { + "domain": "sephora.com" + }, { "domain": "earth.google.com" }, @@ -4253,7 +4256,7 @@ "privacy-test-pages.site" ] }, - "hash": "549a6e76edaf16c1fffced31b97e9553" + "hash": "c34f2a525dac6f93a6d87bad377dbe9d" }, "harmfulApis": { "settings": { @@ -4488,6 +4491,11 @@ "state": "disabled", "hash": "841fa92b9728c9754f050662678f82c7" }, + "notificationPermissions": { + "exceptions": [], + "state": "disabled", + "hash": "728493ef7a1488e4781656d3f9db84aa" + }, "privacyDashboard": { "exceptions": [], "features": { @@ -5440,6 +5448,12 @@ "triblive.com" ] }, + { + "rule": "doubleclick.net/pixel", + "domains": [ + "sbs.com.au" + ] + }, { "rule": "doubleclick.net", "domains": [ @@ -5543,6 +5557,12 @@ "domains": [ "" ] + }, + { + "rule": "ezodn.com", + "domains": [ + "reisezoom.com" + ] } ] }, @@ -5847,7 +5867,8 @@ "pandora.com", "paper-io.com", "rawstory.com", - "usatoday.com" + "usatoday.com", + "washingtonpost.com" ] } ] @@ -6375,6 +6396,16 @@ } ] }, + "mailerlite.com": { + "rules": [ + { + "rule": "mailerlite.com", + "domains": [ + "" + ] + } + ] + }, "maxymiser.net": { "rules": [ { @@ -7216,6 +7247,16 @@ } ] }, + "tremorhub.com": { + "rules": [ + { + "rule": "tremorhub.com/getTVID", + "domains": [ + "sbs.com.au" + ] + } + ] + }, "trustpilot.com": { "rules": [ { @@ -7530,7 +7571,7 @@ "domain": "sundancecatalog.com" } ], - "hash": "f918a51b5651f2e3a99945ba530f3264" + "hash": "374040a08f2e59051d7618509b9f65d4" }, "trackingCookies1p": { "settings": { diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index b4a4f9bb5d..5ba21aebd7 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -460,6 +460,7 @@ 85A1B3B220C6CD9900C18F15 /* CookieStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85A1B3B120C6CD9900C18F15 /* CookieStorage.swift */; }; 85A313972028E78A00327D00 /* release_notes.txt in Resources */ = {isa = PBXBuildFile; fileRef = 85A313962028E78A00327D00 /* release_notes.txt */; }; 85A9C37920E0E00C00073340 /* HomeRow.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 85A9C37820E0E00C00073340 /* HomeRow.xcassets */; }; + 85AD49EE2B6149110085D2D1 /* CookieStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85AD49ED2B6149110085D2D1 /* CookieStorageTests.swift */; }; 85AE668E2097206E0014CF04 /* NotificationView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 85AE668D2097206E0014CF04 /* NotificationView.xib */; }; 85AE6690209724120014CF04 /* NotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85AE668F209724120014CF04 /* NotificationView.swift */; }; 85AFA1212B45D14F0028A504 /* BookmarksMigrationAssertionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85AFA1202B45D14F0028A504 /* BookmarksMigrationAssertionTests.swift */; }; @@ -576,7 +577,6 @@ 987130C7294AAB9F00AB05E0 /* MenuBookmarksViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 987130C1294AAB9E00AB05E0 /* MenuBookmarksViewModelTests.swift */; }; 987130C8294AAB9F00AB05E0 /* BookmarksTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 987130C2294AAB9E00AB05E0 /* BookmarksTestHelpers.swift */; }; 987130C9294AAB9F00AB05E0 /* BookmarkUtilsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 987130C3294AAB9E00AB05E0 /* BookmarkUtilsTests.swift */; }; - 98728E822417E3300033960E /* BrokenSiteInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98728E812417E3300033960E /* BrokenSiteInfo.swift */; }; 9872D205247DCAC100CEF398 /* TabPreviewsSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9872D204247DCAC100CEF398 /* TabPreviewsSource.swift */; }; 9874F9EE2187AFCE00CAF33D /* Themable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9874F9ED2187AFCE00CAF33D /* Themable.swift */; }; 9875E00722316B8400B1373F /* Instruments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9875E00622316B8400B1373F /* Instruments.swift */; }; @@ -878,6 +878,7 @@ F1134ED21F40EF3A00B73467 /* JsonTestDataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1134ECF1F40EBE200B73467 /* JsonTestDataLoader.swift */; }; F1134ED61F40F29F00B73467 /* StatisticsUserDefaultsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1134ED41F40F15800B73467 /* StatisticsUserDefaultsTests.swift */; }; F114C55B1E66EB020018F95F /* NibLoading.swift in Sources */ = {isa = PBXBuildFile; fileRef = F114C55A1E66EB020018F95F /* NibLoading.swift */; }; + F115ED9C2B4EFC8E001A0453 /* TestUtils in Frameworks */ = {isa = PBXBuildFile; productRef = F115ED9B2B4EFC8E001A0453 /* TestUtils */; }; F130D73A1E5776C500C45811 /* OmniBarDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F130D7391E5776C500C45811 /* OmniBarDelegate.swift */; }; F1386BA41E6846C40062FC3C /* TabDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1386BA31E6846C40062FC3C /* TabDelegate.swift */; }; F13B4BC01F180D8A00814661 /* TabsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13B4BBF1F180D8A00814661 /* TabsModel.swift */; }; @@ -1550,6 +1551,7 @@ 85A313962028E78A00327D00 /* release_notes.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = release_notes.txt; path = fastlane/metadata/default/release_notes.txt; sourceTree = ""; }; 85A53EC9200D1FA20010D13F /* FileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileStore.swift; sourceTree = ""; }; 85A9C37820E0E00C00073340 /* HomeRow.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = HomeRow.xcassets; sourceTree = ""; }; + 85AD49ED2B6149110085D2D1 /* CookieStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieStorageTests.swift; sourceTree = ""; }; 85AE668D2097206E0014CF04 /* NotificationView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = NotificationView.xib; sourceTree = ""; }; 85AE668F209724120014CF04 /* NotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationView.swift; sourceTree = ""; }; 85AFA1202B45D14F0028A504 /* BookmarksMigrationAssertionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksMigrationAssertionTests.swift; sourceTree = ""; }; @@ -2128,7 +2130,6 @@ 987130C1294AAB9E00AB05E0 /* MenuBookmarksViewModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MenuBookmarksViewModelTests.swift; sourceTree = ""; }; 987130C2294AAB9E00AB05E0 /* BookmarksTestHelpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarksTestHelpers.swift; sourceTree = ""; }; 987130C3294AAB9E00AB05E0 /* BookmarkUtilsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarkUtilsTests.swift; sourceTree = ""; }; - 98728E812417E3300033960E /* BrokenSiteInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrokenSiteInfo.swift; sourceTree = ""; }; 9872D204247DCAC100CEF398 /* TabPreviewsSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabPreviewsSource.swift; sourceTree = ""; }; 9874F9ED2187AFCE00CAF33D /* Themable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Themable.swift; sourceTree = ""; }; 9875E00622316B8400B1373F /* Instruments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Instruments.swift; sourceTree = ""; }; @@ -2709,6 +2710,7 @@ files = ( F486D3362506A037002D07D7 /* OHHTTPStubs in Frameworks */, F486D3382506A225002D07D7 /* OHHTTPStubsSwift in Frameworks */, + F115ED9C2B4EFC8E001A0453 /* TestUtils in Frameworks */, EEFAB4672A73C230008A38E4 /* NetworkProtectionTestUtils in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -5305,6 +5307,7 @@ 8540BD5123D8C2220057FDD2 /* PreserveLoginsTests.swift */, 850559D123CF710C0055C0D5 /* WebCacheManagerTests.swift */, F198D7971E3A45D90088DA8A /* WKWebViewConfigurationExtensionTests.swift */, + 85AD49ED2B6149110085D2D1 /* CookieStorageTests.swift */, ); name = Web; sourceTree = ""; @@ -5510,7 +5513,6 @@ F1DF09502B039E6E008CC908 /* PrivacyDashboard */ = { isa = PBXGroup; children = ( - 98728E812417E3300033960E /* BrokenSiteInfo.swift */, 1E87615828A1517200C7C5CE /* PrivacyDashboardViewController.swift */, 984147B924F0268D00362052 /* PrivacyDashboard.storyboard */, ); @@ -5751,6 +5753,7 @@ F486D3352506A037002D07D7 /* OHHTTPStubs */, F486D3372506A225002D07D7 /* OHHTTPStubsSwift */, EEFAB4662A73C230008A38E4 /* NetworkProtectionTestUtils */, + F115ED9B2B4EFC8E001A0453 /* TestUtils */, ); productName = DuckDuckGoTests; productReference = 84E341A61E2F7EFB00BDBA6F /* UnitTests.xctest */; @@ -6408,7 +6411,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "# Conditionally embeds PacketTunnelProvider extension for Debug and Alpha builds.\n\n# Conditionally embeds the PacketTunnelProvider extension for debug builds.\\n# To be moved to the Embed App Extensions phase on release.\n\nif [ \"${CONFIGURATION}\" = \"Debug\" ] || [ \"${CONFIGURATION}\" = \"Release\" ] || [ \"${CONFIGURATION}\" = \"Alpha\" || [ \"${CONFIGURATION}\" = \"Alpha Debug\" ]; then\n# Copy the extension \n rsync -r --copy-links \"${CONFIGURATION_BUILD_DIR}/PacketTunnelProvider.appex\" \"${CONFIGURATION_BUILD_DIR}/${PLUGINS_FOLDER_PATH}\"\nfi\n"; + shellScript = "# Conditionally embeds PacketTunnelProvider extension for Debug and Alpha builds.\n\n# Conditionally embeds the PacketTunnelProvider extension for debug builds.\\n# To be moved to the Embed App Extensions phase on release.\n\nif [ \"${CONFIGURATION}\" = \"Debug\" ] || [ \"${CONFIGURATION}\" = \"Release\" ] || [ \"${CONFIGURATION}\" = \"Alpha\" ] || [ \"${CONFIGURATION}\" = \"Alpha Debug\" ]; then\n# Copy the extension \n rsync -r --copy-links \"${CONFIGURATION_BUILD_DIR}/PacketTunnelProvider.appex\" \"${CONFIGURATION_BUILD_DIR}/${PLUGINS_FOLDER_PATH}\"\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -6905,7 +6908,6 @@ 02EC02C429AFA33000557F1A /* AppTPBreakageFormView.swift in Sources */, F15D43201E706CC500BF2CDC /* AutocompleteViewController.swift in Sources */, BD862E092B30F63E0073E2EE /* VPNMetadataCollector.swift in Sources */, - 98728E822417E3300033960E /* BrokenSiteInfo.swift in Sources */, D6E83C682B23B6A3006C8AFB /* FontSettings.swift in Sources */, 31EF52E1281B3BDC0034796E /* AutofillLoginListItemViewModel.swift in Sources */, 1E4FAA6627D8DFC800ADC5B3 /* CompleteDownloadRowViewModel.swift in Sources */, @@ -7055,6 +7057,7 @@ 8521FDE6238D414B00A44CC3 /* FileStoreTests.swift in Sources */, F14E491F1E391CE900DC037C /* URLExtensionTests.swift in Sources */, 85D2187424BF25CD004373D2 /* FaviconsTests.swift in Sources */, + 85AD49EE2B6149110085D2D1 /* CookieStorageTests.swift in Sources */, CBCCF96828885DEE006F4A71 /* AppPrivacyConfigurationTests.swift in Sources */, 310742AB2848E6FD0012660B /* BackForwardMenuHistoryItemURLSanitizerTests.swift in Sources */, 22CB1ED8203DDD2C00D2C724 /* AppDeepLinksTests.swift in Sources */, @@ -9974,7 +9977,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 101.2.2; + version = 104.1.1; }; }; C14882EB27F211A000D59F0C /* XCRemoteSwiftPackageReference "SwiftSoup" */ = { @@ -10205,6 +10208,11 @@ package = 98A16C2928A11BDE00A6C003 /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; productName = NetworkProtectionTestUtils; }; + F115ED9B2B4EFC8E001A0453 /* TestUtils */ = { + isa = XCSwiftPackageProductDependency; + package = 98A16C2928A11BDE00A6C003 /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; + productName = TestUtils; + }; F42D541C29DCA40B004C4FF1 /* DesignResourcesKit */ = { isa = XCSwiftPackageProductDependency; package = F42D541B29DCA40B004C4FF1 /* XCRemoteSwiftPackageReference "DesignResourcesKit" */; diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d85be58c3b..3cf6906a27 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DuckDuckGo/BrowserServicesKit", "state" : { - "revision" : "1f7932fe67a0d8b1ae97e62cb333639353d4772f", - "version" : "101.2.2" + "revision" : "3b10ff8d5d433a4eb45a1d4d25a348033f5102c1", + "version" : "104.1.1" } }, { @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/content-scope-scripts", "state" : { - "revision" : "0b68b0d404d8d4f32296cd84fa160b18b0aeaf44", - "version" : "4.59.1" + "revision" : "38ee7284bac7fa12d822fcaf0677ea3969d15fb1", + "version" : "4.59.2" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/duckduckgo-autofill.git", "state" : { - "revision" : "b972bc0ab6ee1d57a0a18a197dcc31e40ae6ac57", - "version" : "10.0.3" + "revision" : "03d3e3a959dd75afbe8c59b5a203ea676d37555d", + "version" : "10.1.0" } }, { @@ -104,8 +104,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/privacy-dashboard", "state" : { - "revision" : "38336a574e13090764ba09a6b877d15ee514e371", - "version" : "3.1.1" + "revision" : "c67d268bf234760f49034a0fe7a6137a1b216b05", + "version" : "3.2.0" } }, { diff --git a/DuckDuckGo/AppDelegate+Waitlists.swift b/DuckDuckGo/AppDelegate+Waitlists.swift index 598131d836..b377d170b7 100644 --- a/DuckDuckGo/AppDelegate+Waitlists.swift +++ b/DuckDuckGo/AppDelegate+Waitlists.swift @@ -51,11 +51,23 @@ extension AppDelegate { VPNWaitlist.shared.fetchInviteCodeIfAvailable { [weak self] error in guard error == nil else { #if !DEBUG - if error == .alreadyHasInviteCode { + if error == .alreadyHasInviteCode, UIApplication.shared.applicationState == .active { // If the user already has an invite code but their auth token has gone missing, attempt to redeem it again. let tokenStore = NetworkProtectionKeychainTokenStore() let waitlistStorage = VPNWaitlist.shared.waitlistStorage if let inviteCode = waitlistStorage.getWaitlistInviteCode(), !tokenStore.isFeatureActivated { + let pixel: Pixel.Event = .networkProtectionWaitlistRetriedInviteCodeRedemption + + do { + if let token = try tokenStore.fetchToken() { + DailyPixel.fireDailyAndCount(pixel: pixel, withAdditionalParameters: [ "tokenState": "found" ]) + } else { + DailyPixel.fireDailyAndCount(pixel: pixel, withAdditionalParameters: [ "tokenState": "nil" ]) + } + } catch { + DailyPixel.fireDailyAndCount(pixel: pixel, error: error, withAdditionalParameters: [ "tokenState": "error" ]) + } + self?.fetchVPNWaitlistAuthToken(inviteCode: inviteCode) } } @@ -96,7 +108,7 @@ extension AppDelegate { try await NetworkProtectionCodeRedemptionCoordinator().redeem(inviteCode) VPNWaitlist.shared.sendInviteCodeAvailableNotification() - DailyPixel.fire(pixel: .networkProtectionWaitlistNotificationShown) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionWaitlistNotificationShown) } catch {} } } diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index 2040715f88..e997dcd121 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -101,13 +101,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } #endif - if isDebugBuild { - Pixel.isDryRun = true - } else { - Pixel.isDryRun = false - } +#if DEBUG && !ALPHA + Pixel.isDryRun = true +#else + Pixel.isDryRun = false +#endif ContentBlocking.shared.onCriticalError = presentPreemptiveCrashAlert + // Explicitly prepare ContentBlockingUpdating instance before Tabs are created + _ = ContentBlockingUpdating.shared // Can be removed after a couple of versions cleanUpMacPromoExperiment2() diff --git a/DuckDuckGo/AppRatingPrompt.swift b/DuckDuckGo/AppRatingPrompt.swift index 7c85994358..203cf9c864 100644 --- a/DuckDuckGo/AppRatingPrompt.swift +++ b/DuckDuckGo/AppRatingPrompt.swift @@ -25,8 +25,8 @@ protocol AppRatingPromptStorage { var lastAccess: Date? { get set } - var uniqueAccessDays: Int { get set } - + var uniqueAccessDays: Int? { get set } + var lastShown: Date? { get set } } @@ -40,8 +40,8 @@ class AppRatingPrompt { } func registerUsage(onDate date: Date = Date()) { - if !date.isSameDay(storage.lastAccess) { - storage.uniqueAccessDays += 1 + if !date.isSameDay(storage.lastAccess), let currentUniqueAccessDays = storage.uniqueAccessDays { + storage.uniqueAccessDays = currentUniqueAccessDays + 1 } storage.lastAccess = date } @@ -60,33 +60,39 @@ class AppRatingPromptCoreDataStorage: AppRatingPromptStorage { var lastAccess: Date? { get { - return ratingPromptEntity().lastAccess + return ratingPromptEntity()?.lastAccess } set { - ratingPromptEntity().lastAccess = newValue + ratingPromptEntity()?.lastAccess = newValue try? context.save() } } - var uniqueAccessDays: Int { + var uniqueAccessDays: Int? { get { - return Int(ratingPromptEntity().uniqueAccessDays) + guard let ratingPromptEntity = ratingPromptEntity() else { + return nil + } + return Int(ratingPromptEntity.uniqueAccessDays) } set { - ratingPromptEntity().uniqueAccessDays = Int64(newValue) + guard let newValue else { + return + } + ratingPromptEntity()?.uniqueAccessDays = Int64(newValue) try? context.save() } } var lastShown: Date? { get { - return ratingPromptEntity().lastShown + return ratingPromptEntity()?.lastShown } set { - ratingPromptEntity().lastShown = newValue + ratingPromptEntity()?.lastShown = newValue try? context.save() } } @@ -95,14 +101,20 @@ class AppRatingPromptCoreDataStorage: AppRatingPromptStorage { public init() { } - func ratingPromptEntity() -> AppRatingPromptEntity { + func ratingPromptEntity() -> AppRatingPromptEntity? { let fetchRequest: NSFetchRequest = AppRatingPromptEntity.fetchRequest() - - guard let results = try? context.fetch(fetchRequest) else { - fatalError("Error fetching AppRatingPromptEntity") + + let results: [AppRatingPromptEntity] + + do { + results = try context.fetch(fetchRequest) + } catch { + DailyPixel.fireDailyAndCount(pixel: .appRatingPromptFetchError, error: error, includedParameters: [.appVersion]) + return nil } + if let result = results.first { return result } else { diff --git a/DuckDuckGo/ContentBlockingUpdating.swift b/DuckDuckGo/ContentBlockingUpdating.swift index 4c9d70121b..ef1fb29629 100644 --- a/DuckDuckGo/ContentBlockingUpdating.swift +++ b/DuckDuckGo/ContentBlockingUpdating.swift @@ -37,7 +37,7 @@ extension ContentBlockerRulesIdentifier.Difference { } public final class ContentBlockingUpdating { - fileprivate static let shared = ContentBlockingUpdating() + static let shared = ContentBlockingUpdating() private typealias Update = ContentBlockerRulesManager.UpdateEvent struct NewContent: UserContentControllerNewContent { @@ -55,7 +55,7 @@ public final class ContentBlockingUpdating { private(set) var userContentBlockingAssets: AnyPublisher! - init(appSettings: AppSettings = AppDependencyProvider.shared.appSettings, + init(appSettings: AppSettings = AppUserDefaults(), contentBlockerRulesManager: ContentBlockerRulesManagerProtocol = ContentBlocking.shared.contentBlockingManager, privacyConfigurationManager: PrivacyConfigurationManaging = ContentBlocking.shared.privacyConfigurationManager) { diff --git a/DuckDuckGo/EventMapping+NetworkProtectionError.swift b/DuckDuckGo/EventMapping+NetworkProtectionError.swift index a139b4c9a2..2f465c4113 100644 --- a/DuckDuckGo/EventMapping+NetworkProtectionError.swift +++ b/DuckDuckGo/EventMapping+NetworkProtectionError.swift @@ -61,6 +61,10 @@ extension EventMapping where Event == NetworkProtectionError { pixelEvent = .networkProtectionKeychainWriteError params[PixelParameters.keychainFieldName] = field params[PixelParameters.keychainErrorCode] = String(status) + case .keychainUpdateError(field: let field, status: let status): + pixelEvent = .networkProtectionKeychainUpdateError + params[PixelParameters.keychainFieldName] = field + params[PixelParameters.keychainErrorCode] = String(status) case .keychainDeleteError(status: let status): pixelEvent = .networkProtectionKeychainDeleteError params[PixelParameters.keychainErrorCode] = String(status) diff --git a/DuckDuckGo/Feedback/VPNMetadataCollector.swift b/DuckDuckGo/Feedback/VPNMetadataCollector.swift index 3ab9f25421..d1daf056bb 100644 --- a/DuckDuckGo/Feedback/VPNMetadataCollector.swift +++ b/DuckDuckGo/Feedback/VPNMetadataCollector.swift @@ -195,7 +195,7 @@ final class DefaultVPNMetadataCollector: VPNMetadataCollector { connectedServerIP: connectedServerIP) } - private func lastDisconnectError() async -> String { + public func lastDisconnectError() async -> String { if #available(iOS 16, *) { guard let tunnelManager = try? await NETunnelProviderManager.loadAllFromPreferences().first else { return "none" diff --git a/DuckDuckGo/KeychainItemsDebugViewController.swift b/DuckDuckGo/KeychainItemsDebugViewController.swift index 8caa29ec13..86bdf02dd0 100644 --- a/DuckDuckGo/KeychainItemsDebugViewController.swift +++ b/DuckDuckGo/KeychainItemsDebugViewController.swift @@ -26,6 +26,7 @@ private struct KeychainItem { let secClass: SecClass let service: String? let account: String? + let accessGroup: String? let valueData: Data? let creationDate: Any? let modificationDate: Any? @@ -39,6 +40,7 @@ private struct KeychainItem { return """ Service: \(service ?? "nil") Account: \(account ?? "nil") + Access Group: \(accessGroup ?? "nil") Value as String: \(value ?? "nil") Value data: \(String(describing: valueData)) Creation date: \(String(describing: creationDate)) @@ -54,7 +56,8 @@ private enum SecClass: CaseIterable { case classCertificate case classKey case classIdentity - + case accessGroup + var secClassCFString: CFString { switch self { case .internetPassword: @@ -67,6 +70,8 @@ private enum SecClass: CaseIterable { return kSecClassKey case .classIdentity: return kSecClassIdentity + case .accessGroup: + return kSecAttrAccessGroup } } @@ -82,6 +87,8 @@ private enum SecClass: CaseIterable { return "kSecClassKey" case .classIdentity: return "kSecClassIdentity" + case .accessGroup: + return "kSecAttrAccessGroup" } } @@ -106,6 +113,7 @@ private enum SecClass: CaseIterable { KeychainItem(secClass: self, service: $0[kSecAttrService as String] as? String, account: $0[kSecAttrAccount as String] as? String, + accessGroup: $0[kSecAttrAccessGroup as String] as? String, valueData: $0[kSecValueData as String] as? Data, creationDate: $0[kSecAttrCreationDate as String, default: "no creation"], modificationDate: $0[kSecAttrModificationDate as String, default: "no modification"]) diff --git a/DuckDuckGo/MainViewController+Segues.swift b/DuckDuckGo/MainViewController+Segues.swift index 7002be5231..0d994f5699 100644 --- a/DuckDuckGo/MainViewController+Segues.swift +++ b/DuckDuckGo/MainViewController+Segues.swift @@ -124,7 +124,6 @@ extension MainViewController { os_log(#function, log: .generalLog, type: .debug) hideAllHighlightsIfNeeded() - let brokenSiteInfo = currentTab?.getCurrentWebsiteInfo() guard let currentURL = currentTab?.url, let privacyInfo = currentTab?.makePrivacyInfo(url: currentURL) else { assertionFailure("Missing fundamental data") @@ -133,11 +132,12 @@ extension MainViewController { let storyboard = UIStoryboard(name: "PrivacyDashboard", bundle: nil) let controller = storyboard.instantiateInitialViewController { coder in - PrivacyDashboardViewController(coder: coder, + PrivacyDashboardViewController(coder: coder, privacyInfo: privacyInfo, privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager, contentBlockingManager: ContentBlocking.shared.contentBlockingManager, - initMode: .reportBrokenSite) + initMode: .reportBrokenSite, + breakageAdditionalInfo: self.currentTab?.makeBreakageAdditionalInfo()) } guard let controller = controller else { @@ -147,7 +147,6 @@ extension MainViewController { currentTab?.privacyDashboard = controller controller.popoverPresentationController?.delegate = controller - controller.brokenSiteInfo = brokenSiteInfo if UIDevice.current.userInterfaceIdiom == .pad { controller.modalPresentationStyle = .formSheet diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index b112492b87..c9e0cafaaf 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -31,6 +31,10 @@ import Persistence import PrivacyDashboard import Networking +#if NETWORK_PROTECTION +import NetworkProtection +#endif + // swiftlint:disable file_length // swiftlint:disable type_body_length class MainViewController: UIViewController { @@ -98,6 +102,10 @@ class MainViewController: UIViewController { private var syncFeatureFlagsCancellable: AnyCancellable? private var favoritesDisplayModeCancellable: AnyCancellable? private var emailCancellables = Set() + +#if NETWORK_PROTECTION + private var netpCancellables = Set() +#endif private lazy var featureFlagger = AppDependencyProvider.shared.featureFlagger @@ -139,6 +147,9 @@ class MainViewController: UIViewController { private var skipSERPFlow = true private var keyboardHeight: CGFloat = 0.0 + + var postClear: (() -> Void)? + var clearInProgress = false required init?(coder: NSCoder) { fatalError("Use init?(code:") @@ -244,6 +255,10 @@ class MainViewController: UIViewController { addLaunchTabNotificationObserver() subscribeToEmailProtectionStatusNotifications() +#if NETWORK_PROTECTION && SUBSCRIPTION + subscribeToNetworkProtectionSubscriptionEvents() +#endif + findInPageView.delegate = self findInPageBottomLayoutConstraint.constant = 0 registerForKeyboardNotifications() @@ -773,22 +788,30 @@ class MainViewController: UIViewController { } func loadUrlInNewTab(_ url: URL, reuseExisting: Bool = false, inheritedAttribution: AdClickAttributionLogic.State?) { - allowContentUnderflow = false - viewCoordinator.navigationBarContainer.alpha = 1 - loadViewIfNeeded() - if reuseExisting, let existing = tabManager.first(withUrl: url) { - selectTab(existing) - return - } else if reuseExisting, let existing = tabManager.firstHomeTab() { - tabManager.selectTab(existing) - loadUrl(url) + func worker() { + allowContentUnderflow = false + viewCoordinator.navigationBarContainer.alpha = 1 + loadViewIfNeeded() + if reuseExisting, let existing = tabManager.first(withUrl: url) { + selectTab(existing) + return + } else if reuseExisting, let existing = tabManager.firstHomeTab() { + tabManager.selectTab(existing) + loadUrl(url) + } else { + addTab(url: url, inheritedAttribution: inheritedAttribution) + } + refreshOmniBar() + refreshTabIcon() + refreshControls() + tabsBarController?.refresh(tabsModel: tabManager.model) + } + + if clearInProgress { + postClear = worker } else { - addTab(url: url, inheritedAttribution: inheritedAttribution) + worker() } - refreshOmniBar() - refreshTabIcon() - refreshControls() - tabsBarController?.refresh(tabsModel: tabManager.model) } func enterSearch() { @@ -836,9 +859,6 @@ class MainViewController: UIViewController { if tabManager.current(createIfNeeded: true) == nil { fatalError("failed to create tab") } - - // Likely this hasn't happened yet so the publishers won't be loaded and will block the webview from loading - _ = ContentBlocking.shared.contentBlockingManager.scheduleCompilation() } guard let tab = currentTab else { fatalError("no tab") } @@ -857,8 +877,13 @@ class MainViewController: UIViewController { func select(tabAt index: Int) { viewCoordinator.navigationBarContainer.alpha = 1 allowContentUnderflow = false - let tab = tabManager.select(tabAt: index) - select(tab: tab) + + if tabManager.model.tabs.indices.contains(index) { + let tab = tabManager.select(tabAt: index) + select(tab: tab) + } else { + assertionFailure("Invalid index selected") + } } fileprivate func select(tab: TabViewController) { @@ -1221,6 +1246,50 @@ class MainViewController: UIViewController { .store(in: &emailCancellables) } +#if NETWORK_PROTECTION && SUBSCRIPTION + private func subscribeToNetworkProtectionSubscriptionEvents() { + NotificationCenter.default.publisher(for: .accountDidSignIn) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + self?.onNetworkProtectionAccountSignIn(notification) + } + .store(in: &netpCancellables) + NotificationCenter.default.publisher(for: .accountDidSignOut) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + self?.onNetworkProtectionAccountSignOut(notification) + } + .store(in: &netpCancellables) + } + + @objc + private func onNetworkProtectionAccountSignIn(_ notification: Notification) { + guard let token = AccountManager().accessToken else { + assertionFailure("[NetP Subscription] AccountManager signed in but token could not be retrieved") + return + } + + Task { + do { + try await NetworkProtectionCodeRedemptionCoordinator().exchange(accessToken: token) + print("[NetP Subscription] Exchanged access token for auth token successfully") + } catch { + print("[NetP Subscription] Failed to exchange access token for auth token: \(error)") + } + } + } + + @objc + private func onNetworkProtectionAccountSignOut(_ notification: Notification) { + do { + try NetworkProtectionKeychainTokenStore().deleteToken() + print("[NetP Subscription] Deleted NetP auth token after signing out from Privacy Pro") + } catch { + print("[NetP Subscription] Failed to delete NetP auth token after signing out from Privacy Pro: \(error)") + } + } +#endif + @objc private func onDuckDuckGoEmailSignIn(_ notification: Notification) { fireEmailPixel(.emailEnabled, notification: notification) @@ -2026,6 +2095,11 @@ extension MainViewController: AutoClearWorker { } func forgetData() { + guard !clearInProgress else { + assertionFailure("Shouldn't get called multiple times") + return + } + clearInProgress = true URLSession.shared.configuration.urlCache?.removeAllCachedResponses() let pixel = TimedPixel(.forgetAllDataCleared) @@ -2040,6 +2114,10 @@ extension MainViewController: AutoClearWorker { } self.refreshUIAfterClear() + self.clearInProgress = false + + self.postClear?() + self.postClear = nil } } diff --git a/DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift b/DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift index 18e83a83a9..8698a6df68 100644 --- a/DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift +++ b/DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift @@ -53,11 +53,12 @@ extension NetworkProtectionKeychainTokenStore { } extension NetworkProtectionCodeRedemptionCoordinator { - convenience init() { + convenience init(isManualCodeRedemptionFlow: Bool = false) { let settings = VPNSettings(defaults: .networkProtectionGroupDefaults) self.init( environment: settings.selectedEnvironment, tokenStore: NetworkProtectionKeychainTokenStore(), + isManualCodeRedemptionFlow: isManualCodeRedemptionFlow, errorEvents: .networkProtectionAppDebugEvents ) } diff --git a/DuckDuckGo/NetworkProtectionDebugViewController.swift b/DuckDuckGo/NetworkProtectionDebugViewController.swift index e50c45e17f..55c4445f64 100644 --- a/DuckDuckGo/NetworkProtectionDebugViewController.swift +++ b/DuckDuckGo/NetworkProtectionDebugViewController.swift @@ -43,6 +43,7 @@ final class NetworkProtectionDebugViewController: UITableViewController { Sections.registrationKey: "Registration Key", Sections.notifications: "Notifications", Sections.networkPath: "Network Path", + Sections.lastDisconnectError: "Last Disconnect Error", Sections.connectionTest: "Connection Test", Sections.vpnConfiguration: "VPN Configuration" @@ -56,6 +57,7 @@ final class NetworkProtectionDebugViewController: UITableViewController { case notifications case connectionTest case networkPath + case lastDisconnectError case vpnConfiguration } @@ -90,6 +92,10 @@ final class NetworkProtectionDebugViewController: UITableViewController { case networkPath } + enum LastDisconnectErrorRows: Int, CaseIterable { + case lastDisconnectError + } + enum ConnectionTestRows: Int, CaseIterable { case runConnectionTest } @@ -106,6 +112,7 @@ final class NetworkProtectionDebugViewController: UITableViewController { private let pathMonitor = NWPathMonitor() private var currentNetworkPath: String? + private var lastDisconnectError: String? private var baseConfigurationData: String? private var fullProtocolConfigurationData: String? @@ -138,6 +145,7 @@ final class NetworkProtectionDebugViewController: UITableViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + loadLastDisconnectError() loadConfigurationData() startPathMonitor() } @@ -188,6 +196,9 @@ final class NetworkProtectionDebugViewController: UITableViewController { case .networkPath: configure(cell, forNetworkPathRow: indexPath.row) + case .lastDisconnectError: + configure(cell, forLastDisconnectErrorRow: indexPath.row) + case .connectionTest: configure(cell, forConnectionTestRow: indexPath.row) @@ -209,6 +220,7 @@ final class NetworkProtectionDebugViewController: UITableViewController { case .registrationKey: return RegistrationKeyRows.allCases.count case .notifications: return NotificationsRows.allCases.count case .networkPath: return NetworkPathRows.allCases.count + case .lastDisconnectError: return LastDisconnectErrorRows.allCases.count case .connectionTest: return ConnectionTestRows.allCases.count + connectionTestResults.count case .vpnConfiguration: return ConfigurationRows.allCases.count case .none: return 0 @@ -235,6 +247,8 @@ final class NetworkProtectionDebugViewController: UITableViewController { didSelectTestNotificationAction(at: indexPath) case .networkPath: break + case .lastDisconnectError: + break case .connectionTest: if indexPath.row == connectionTestResults.count { Task { @@ -394,6 +408,20 @@ final class NetworkProtectionDebugViewController: UITableViewController { pathMonitor.start(queue: .main) } + // MARK: Last disconnect error + + private func configure(_ cell: UITableViewCell, forLastDisconnectErrorRow row: Int) { + cell.textLabel?.font = .monospacedSystemFont(ofSize: 13.0, weight: .regular) + cell.textLabel?.text = lastDisconnectError ?? "Loading Last Disconnect Error..." + } + + private func loadLastDisconnectError() { + Task { @MainActor in + lastDisconnectError = await DefaultVPNMetadataCollector().lastDisconnectError() + tableView.reloadData() + } + } + // MARK: Connection Test private func configure(_ cell: UITableViewCell, forConnectionTestRow row: Int) { diff --git a/DuckDuckGo/NetworkProtectionInviteViewModel.swift b/DuckDuckGo/NetworkProtectionInviteViewModel.swift index 62b82becb7..0ee06a582e 100644 --- a/DuckDuckGo/NetworkProtectionInviteViewModel.swift +++ b/DuckDuckGo/NetworkProtectionInviteViewModel.swift @@ -84,19 +84,6 @@ final class NetworkProtectionInviteViewModel: ObservableObject { completion() } - // MARK: Dev only. Will be removed during https://app.asana.com/0/0/1205084446087078/f - - @MainActor - func clear() async { - errorText = "" - do { - try NetworkProtectionKeychainTokenStore().deleteToken() - updateAuthenticatedText() - } catch { - errorText = "Could not clear token" - } - } - @Published var redeemedText: String? private func updateAuthenticatedText() { diff --git a/DuckDuckGo/NetworkProtectionRootView.swift b/DuckDuckGo/NetworkProtectionRootView.swift index 0ec4d60535..0be2ed7b31 100644 --- a/DuckDuckGo/NetworkProtectionRootView.swift +++ b/DuckDuckGo/NetworkProtectionRootView.swift @@ -29,7 +29,7 @@ struct NetworkProtectionRootView: View { var body: some View { let inviteViewModel = NetworkProtectionInviteViewModel( - redemptionCoordinator: NetworkProtectionCodeRedemptionCoordinator(), + redemptionCoordinator: NetworkProtectionCodeRedemptionCoordinator(isManualCodeRedemptionFlow: true), completion: inviteCompletion ) switch model.initialViewKind { diff --git a/DuckDuckGo/NetworkProtectionStatusViewModel.swift b/DuckDuckGo/NetworkProtectionStatusViewModel.swift index 881d49e8b2..ab3ca1f57b 100644 --- a/DuckDuckGo/NetworkProtectionStatusViewModel.swift +++ b/DuckDuckGo/NetworkProtectionStatusViewModel.swift @@ -156,7 +156,7 @@ final class NetworkProtectionStatusViewModel: ObservableObject { // Set up a delayed publisher to fire just once that reenables the toggle // Each event cancels the previous delayed publisher - isLoadingPublisher + $shouldDisableToggle .filter { $0 } .map { Just(!$0) diff --git a/DuckDuckGo/PrivacyDashboard/BrokenSiteInfo.swift b/DuckDuckGo/PrivacyDashboard/BrokenSiteInfo.swift deleted file mode 100644 index a6395d695d..0000000000 --- a/DuckDuckGo/PrivacyDashboard/BrokenSiteInfo.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// BrokenSiteInfo.swift -// DuckDuckGo -// -// Copyright © 2020 DuckDuckGo. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation -import Core - -public struct BrokenSiteInfo { - - static let allowedQueryReservedCharacters = CharacterSet(charactersIn: ",") - - private struct Keys { - static let url = "siteUrl" - static let category = "category" - static let reportFlow = "reportFlow" - static let description = "description" - static let upgradedHttps = "upgradedHttps" - static let tds = "tds" - static let blockedTrackers = "blockedTrackers" - static let surrogates = "surrogates" - static let atb = "atb" - static let os = "os" - static let manufacturer = "manufacturer" - static let model = "model" - static let siteType = "siteType" - static let gpc = "gpc" - static let ampUrl = "ampUrl" - static let urlParametersRemoved = "urlParametersRemoved" - static let protectionsState = "protectionsState" - } - - public enum Source: String { - case appMenu = "menu" - case dashboard - } - - private let url: URL? - private let httpsUpgrade: Bool - private let blockedTrackerDomains: [String] - private let installedSurrogates: [String] - private let isDesktop: Bool - private let tdsETag: String? - private let ampUrl: String? - private let urlParametersRemoved: Bool - private let model: String - private let manufacturer: String - private let systemVersion: String - private let gpc: Bool - private let protectionsState: Bool - - public init(url: URL?, httpsUpgrade: Bool, - blockedTrackerDomains: [String], - installedSurrogates: [String], - isDesktop: Bool, - tdsETag: String?, - ampUrl: String?, - urlParametersRemoved: Bool, - protectionsState: Bool, - model: String = UIDevice.current.model, - manufacturer: String = "Apple", - systemVersion: String = UIDevice.current.systemVersion, - gpc: Bool? = nil) { - self.url = url - self.httpsUpgrade = httpsUpgrade - self.blockedTrackerDomains = blockedTrackerDomains - self.installedSurrogates = installedSurrogates - self.isDesktop = isDesktop - self.tdsETag = tdsETag - self.ampUrl = ampUrl - self.urlParametersRemoved = urlParametersRemoved - - self.model = model - self.manufacturer = manufacturer - self.systemVersion = systemVersion - self.protectionsState = protectionsState - - if let gpcParam = gpc { - self.gpc = gpcParam - } else { - self.gpc = AppDependencyProvider.shared.appSettings.sendDoNotSell - } - } - - func send(with category: String?, description: String, source: Source) { - - let parameters: [String: String] = [ - Keys.url: normalize(url), - Keys.category: category ?? "", - Keys.description: description, - Keys.reportFlow: source.rawValue, - Keys.upgradedHttps: httpsUpgrade ? "true" : "false", - Keys.siteType: isDesktop ? "desktop" : "mobile", - Keys.tds: tdsETag?.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) ?? "", - Keys.blockedTrackers: blockedTrackerDomains.joined(separator: ","), - Keys.surrogates: installedSurrogates.joined(separator: ","), - Keys.atb: StatisticsUserDefaults().atb ?? "", - Keys.os: systemVersion, - Keys.manufacturer: manufacturer, - Keys.model: model, - Keys.gpc: gpc ? "true" : "false", - Keys.ampUrl: ampUrl ?? "", - Keys.urlParametersRemoved: urlParametersRemoved ? "true" : "false", - Keys.protectionsState: protectionsState ? "true" : "false" - ] - - Pixel.fire(pixel: .brokenSiteReport, - withAdditionalParameters: parameters, - allowedQueryReservedCharacters: BrokenSiteInfo.allowedQueryReservedCharacters) - } - - private func normalize(_ url: URL?) -> String { - return url?.normalized()?.absoluteString ?? "" - } - -} diff --git a/DuckDuckGo/PrivacyDashboard/PrivacyDashboardViewController.swift b/DuckDuckGo/PrivacyDashboard/PrivacyDashboardViewController.swift index c4a2e67877..586c8e99d9 100644 --- a/DuckDuckGo/PrivacyDashboard/PrivacyDashboardViewController.swift +++ b/DuckDuckGo/PrivacyDashboard/PrivacyDashboardViewController.swift @@ -23,6 +23,7 @@ import Combine import Core import BrowserServicesKit import PrivacyDashboard +import Common /// View controller used for `Privacy Dasboard` or `Report broken site`, the web content is chosen at init time setting the correct `initMode` class PrivacyDashboardViewController: UIViewController { @@ -39,22 +40,31 @@ class PrivacyDashboardViewController: UIViewController { private let privacyDashboardController: PrivacyDashboardController private let privacyConfigurationManager: PrivacyConfigurationManaging private let contentBlockingManager: ContentBlockerRulesManager - public var brokenSiteInfo: BrokenSiteInfo? + public var breakageAdditionalInfo: BreakageAdditionalInfo? - var source: BrokenSiteInfo.Source { + var source: WebsiteBreakage.Source { initMode == .reportBrokenSite ? .appMenu : .dashboard } + private let websiteBreakageReporter: WebsiteBreakageReporter = { + WebsiteBreakageReporter(pixelHandler: { parameters in + Pixel.fire(pixel: .brokenSiteReport, + withAdditionalParameters: parameters, + allowedQueryReservedCharacters: WebsiteBreakage.allowedQueryReservedCharacters) + }, keyValueStoring: UserDefaults.standard) + }() + init?(coder: NSCoder, privacyInfo: PrivacyInfo?, privacyConfigurationManager: PrivacyConfigurationManaging, contentBlockingManager: ContentBlockerRulesManager, - initMode: Mode) { + initMode: Mode, + breakageAdditionalInfo: BreakageAdditionalInfo?) { self.privacyDashboardController = PrivacyDashboardController(privacyInfo: privacyInfo) self.privacyConfigurationManager = privacyConfigurationManager self.contentBlockingManager = contentBlockingManager self.initMode = initMode - + self.breakageAdditionalInfo = breakageAdditionalInfo super.init(coder: coder) self.privacyDashboardController.privacyDashboardDelegate = self @@ -126,6 +136,8 @@ extension PrivacyDashboardViewController: Themable { } } +// MARK: - PrivacyDashboardControllerDelegate + extension PrivacyDashboardViewController: PrivacyDashboardControllerDelegate { func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, didChangeProtectionSwitch protectionState: ProtectionState) { @@ -159,6 +171,8 @@ extension PrivacyDashboardViewController: PrivacyDashboardControllerDelegate { } } +// MARK: - PrivacyDashboardNavigationDelegate + extension PrivacyDashboardViewController: PrivacyDashboardNavigationDelegate { func privacyDashboardController(_ privacyDashboardController: PrivacyDashboard.PrivacyDashboardController, didSetHeight height: Int) { @@ -171,23 +185,77 @@ extension PrivacyDashboardViewController: PrivacyDashboardNavigationDelegate { } } +// MARK: - PrivacyDashboardReportBrokenSiteDelegate + extension PrivacyDashboardViewController: PrivacyDashboardReportBrokenSiteDelegate { - - func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, reportBrokenSiteDidChangeProtectionSwitch protectionState: ProtectionState) { + + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, + reportBrokenSiteDidChangeProtectionSwitch protectionState: ProtectionState) { privacyDashboardProtectionSwitchChangeHandler(state: protectionState) } - func privacyDashboardController(_ privacyDashboardController: PrivacyDashboard.PrivacyDashboardController, didRequestSubmitBrokenSiteReportWithCategory category: String, description: String) { - - guard let brokenSiteInfo = brokenSiteInfo else { - assertionFailure("brokenSiteInfo not initialised") - return + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboard.PrivacyDashboardController, + didRequestSubmitBrokenSiteReportWithCategory category: String, description: String) { + + do { + let breakageReport = try makeWebsiteBreakage(category: category, description: description) + try websiteBreakageReporter.report(breakage: breakageReport) + } catch { + os_log("Failed to generate or send the website breakage report: %@", type: .error, error.localizedDescription) } - brokenSiteInfo.send(with: category, description: description, source: source) ActionMessageView.present(message: UserText.feedbackSumbittedConfirmation) privacyDashboardCloseHandler() } } extension PrivacyDashboardViewController: UIPopoverPresentationControllerDelegate {} + +extension PrivacyDashboardViewController { + + struct BreakageAdditionalInfo { + let currentURL: URL + let httpsForced: Bool + let ampURLString: String + let urlParametersRemoved: Bool + let isDesktop: Bool + let error: Error? + let httpStatusCode: Int? + } + + enum WebsiteBreakageError: Error { + case failedToFetchTheCurrentWebsiteInfo + } + + private func makeWebsiteBreakage(category: String, description: String) throws -> WebsiteBreakage { + + guard let privacyInfo = privacyDashboardController.privacyInfo, + let breakageAdditionalInfo = breakageAdditionalInfo else { + throw WebsiteBreakageError.failedToFetchTheCurrentWebsiteInfo + } + + let blockedTrackerDomains = privacyInfo.trackerInfo.trackersBlocked.compactMap { $0.domain } + let configuration = ContentBlocking.shared.privacyConfigurationManager.privacyConfig + let protectionsState = configuration.isFeature(.contentBlocking, enabledForDomain: breakageAdditionalInfo.currentURL.host) + + return WebsiteBreakage(siteUrl: breakageAdditionalInfo.currentURL, + category: category, + description: description, + osVersion: "\(ProcessInfo().operatingSystemVersion.majorVersion)", + manufacturer: "Apple", + upgradedHttps: breakageAdditionalInfo.httpsForced, + tdsETag: ContentBlocking.shared.contentBlockingManager.currentMainRules?.etag ?? "", + blockedTrackerDomains: blockedTrackerDomains, + installedSurrogates: privacyInfo.trackerInfo.installedSurrogates.map { $0 }, + isGPCEnabled: AppDependencyProvider.shared.appSettings.sendDoNotSell, + ampURL: breakageAdditionalInfo.ampURLString, + urlParametersRemoved: breakageAdditionalInfo.urlParametersRemoved, + protectionsState: protectionsState, + reportFlow: source, + siteType: breakageAdditionalInfo.isDesktop ? .desktop : .mobile, + atb: StatisticsUserDefaults().atb ?? "", + model: UIDevice.current.model, + error: breakageAdditionalInfo.error, + httpStatusCode: breakageAdditionalInfo.httpStatusCode) + } +} diff --git a/DuckDuckGo/Settings.bundle/Root.plist b/DuckDuckGo/Settings.bundle/Root.plist index f6b1bab836..bcd44a47ed 100644 --- a/DuckDuckGo/Settings.bundle/Root.plist +++ b/DuckDuckGo/Settings.bundle/Root.plist @@ -6,7 +6,7 @@ DefaultValue - 7.106.0 + 7.107.0 Key version Title diff --git a/DuckDuckGo/SettingsCustomizeView.swift b/DuckDuckGo/SettingsCustomizeView.swift index 034b268090..6570427420 100644 --- a/DuckDuckGo/SettingsCustomizeView.swift +++ b/DuckDuckGo/SettingsCustomizeView.swift @@ -37,7 +37,7 @@ struct SettingsCustomizeView: View { SettingsCellView(label: UserText.settingsAutocomplete, accesory: .toggle(isOn: viewModel.autocompleteBinding)) - if viewModel.state.speechRecognitionEnabled { + if viewModel.state.speechRecognitionAvailable { SettingsCellView(label: UserText.settingsVoiceSearch, accesory: .toggle(isOn: viewModel.voiceSearchEnabledBinding)) } diff --git a/DuckDuckGo/SettingsState.swift b/DuckDuckGo/SettingsState.swift index e6568efc7d..f3e00f516a 100644 --- a/DuckDuckGo/SettingsState.swift +++ b/DuckDuckGo/SettingsState.swift @@ -78,7 +78,7 @@ struct SettingsState { // Features var debugModeEnabled: Bool var voiceSearchEnabled: Bool - var speechRecognitionEnabled: Bool + var speechRecognitionAvailable: Bool // Returns if the device has speech recognition available var loginsEnabled: Bool // Network Protection properties @@ -108,7 +108,7 @@ struct SettingsState { version: "0.0.0.0", debugModeEnabled: false, voiceSearchEnabled: false, - speechRecognitionEnabled: false, + speechRecognitionAvailable: false, loginsEnabled: false, networkProtection: NetworkProtection(enabled: false, status: ""), subscription: Subscription(enabled: false, canPurchase: false, diff --git a/DuckDuckGo/SettingsViewModel.swift b/DuckDuckGo/SettingsViewModel.swift index fd452ddeca..899b4eef6f 100644 --- a/DuckDuckGo/SettingsViewModel.swift +++ b/DuckDuckGo/SettingsViewModel.swift @@ -43,6 +43,7 @@ final class SettingsViewModel: ObservableObject { private var legacyViewProvider: SettingsLegacyViewProvider private lazy var versionProvider: AppVersion = AppVersion.shared private var accountManager: AccountManager + private let voiceSearchHelper: VoiceSearchHelperProtocol #if NETWORK_PROTECTION private let connectionObserver = ConnectionStatusObserverThroughSession() @@ -160,7 +161,7 @@ final class SettingsViewModel: ObservableObject { self.enableVoiceSearch { [weak self] result in DispatchQueue.main.async { self?.state.voiceSearchEnabled = result - self?.appSettings.voiceSearchEnabled = result + self?.voiceSearchHelper.enableVoiceSearch(true) if !result { // Permission is denied self?.shouldShowNoMicrophonePermissionAlert = true @@ -168,7 +169,7 @@ final class SettingsViewModel: ObservableObject { } } } else { - self.appSettings.voiceSearchEnabled = false + self.voiceSearchHelper.enableVoiceSearch(false) self.state.voiceSearchEnabled = false } } @@ -198,10 +199,12 @@ final class SettingsViewModel: ObservableObject { init(state: SettingsState? = nil, legacyViewProvider: SettingsLegacyViewProvider, accountManager: AccountManager, + voiceSearchHelper: VoiceSearchHelperProtocol = AppDependencyProvider.shared.voiceSearchHelper, navigateOnAppearDestination: SettingsSection = .none) { self.state = SettingsState.defaults self.legacyViewProvider = legacyViewProvider self.accountManager = accountManager + self.voiceSearchHelper = voiceSearchHelper self.onAppearNavigationTarget = navigateOnAppearDestination } } @@ -229,8 +232,8 @@ extension SettingsViewModel { activeWebsiteAccount: nil, version: versionProvider.versionAndBuildNumber, debugModeEnabled: featureFlagger.isFeatureOn(.debugMenu) || isDebugBuild, - voiceSearchEnabled: AppDependencyProvider.shared.voiceSearchHelper.isSpeechRecognizerAvailable, - speechRecognitionEnabled: AppDependencyProvider.shared.voiceSearchHelper.isSpeechRecognizerAvailable, + voiceSearchEnabled: AppDependencyProvider.shared.voiceSearchHelper.isVoiceSearchEnabled, + speechRecognitionAvailable: AppDependencyProvider.shared.voiceSearchHelper.isSpeechRecognizerAvailable, loginsEnabled: featureFlagger.isFeatureOn(.autofillAccessCredentialManagement), networkProtection: getNetworkProtectionState(), subscription: getSubscriptionState(), @@ -299,7 +302,6 @@ extension SettingsViewModel { completion(false) return } - AppDependencyProvider.shared.voiceSearchHelper.enableVoiceSearch(true) completion(true) } } diff --git a/DuckDuckGo/SyncSettingsViewController.swift b/DuckDuckGo/SyncSettingsViewController.swift index d8b8dc28f4..f49d56003c 100644 --- a/DuckDuckGo/SyncSettingsViewController.swift +++ b/DuckDuckGo/SyncSettingsViewController.swift @@ -97,6 +97,7 @@ class SyncSettingsViewController: UIHostingController { viewModel.isConnectingDevicesAvailable = featureFlags.contains(.connectFlows) viewModel.isAccountCreationAvailable = featureFlags.contains(.accountCreation) viewModel.isAccountRecoveryAvailable = featureFlags.contains(.accountRecovery) + viewModel.isAppVersionNotSupported = featureFlags.unavailableReason == .appVersionNotSupported } .store(in: &cancellables) } diff --git a/DuckDuckGo/TabViewController.swift b/DuckDuckGo/TabViewController.swift index f54d55883b..0e58ca1c50 100644 --- a/DuckDuckGo/TabViewController.swift +++ b/DuckDuckGo/TabViewController.swift @@ -110,6 +110,7 @@ class TabViewController: UIViewController { private var httpsForced: Bool = false private var lastUpgradedURL: URL? private var lastError: Error? + private var lastHttpStatusCode: Int? private var shouldReloadOnError = false private var failingUrls = Set() private var urlProvidedBasicAuthCredential: (credential: URLCredential, url: URL)? @@ -722,7 +723,6 @@ class TabViewController: UIViewController { controller.popoverPresentationController?.sourceRect = iconView.bounds } privacyDashboard = controller - privacyDashboard?.brokenSiteInfo = getCurrentWebsiteInfo() } if let controller = segue.destination as? FullscreenDaxDialogViewController { @@ -749,7 +749,8 @@ class TabViewController: UIViewController { privacyInfo: privacyInfo, privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager, contentBlockingManager: ContentBlocking.shared.contentBlockingManager, - initMode: .privacyDashboard) + initMode: .privacyDashboard, + breakageAdditionalInfo: makeBreakageAdditionalInfo()) } private func addTextSizeObserver() { @@ -912,27 +913,24 @@ class TabViewController: UIViewController { webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.canGoBack)) webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.title)) } - - public func getCurrentWebsiteInfo() -> BrokenSiteInfo { - let blockedTrackerDomains = privacyInfo?.trackerInfo.trackersBlocked.compactMap { $0.domain } ?? [] - - let configuration = ContentBlocking.shared.privacyConfigurationManager.privacyConfig - let protectionsState = configuration.isFeature(.contentBlocking, enabledForDomain: url?.host) - return BrokenSiteInfo(url: url, - httpsUpgrade: httpsForced, - blockedTrackerDomains: blockedTrackerDomains, - installedSurrogates: privacyInfo?.trackerInfo.installedSurrogates.map { $0 } ?? [], - isDesktop: tabModel.isDesktop, - tdsETag: ContentBlocking.shared.contentBlockingManager.currentMainRules?.etag ?? "", - ampUrl: linkProtection.lastAMPURLString, - urlParametersRemoved: linkProtection.urlParametersRemoved, - protectionsState: protectionsState) + public func makeBreakageAdditionalInfo() -> PrivacyDashboardViewController.BreakageAdditionalInfo? { + + guard let currentURL = url else { + return nil + } + return PrivacyDashboardViewController.BreakageAdditionalInfo(currentURL: currentURL, + httpsForced: httpsForced, + ampURLString: linkProtection.lastAMPURLString ?? "", + urlParametersRemoved: linkProtection.urlParametersRemoved, + isDesktop: tabModel.isDesktop, + error: lastError, + httpStatusCode: lastHttpStatusCode) } - + public func print() { let printFormatter = webView.viewPrintFormatter() - + let printInfo = UIPrintInfo(dictionary: nil) printInfo.jobName = Bundle.main.infoDictionary!["CFBundleName"] as? String ?? "DuckDuckGo" printInfo.outputType = .general @@ -1067,6 +1065,7 @@ extension TabViewController: WKNavigationDelegate { let httpResponse = navigationResponse.response as? HTTPURLResponse let isSuccessfulResponse = httpResponse?.isSuccessfulResponse ?? false + lastHttpStatusCode = httpResponse?.statusCode let didMarkAsInternal = internalUserDecider.markUserAsInternalIfNeeded(forUrl: webView.url, response: httpResponse) if didMarkAsInternal { diff --git a/DuckDuckGoTests/AppRatingPromptStorageTests.swift b/DuckDuckGoTests/AppRatingPromptStorageTests.swift index 1016401122..e304b9257f 100644 --- a/DuckDuckGoTests/AppRatingPromptStorageTests.swift +++ b/DuckDuckGoTests/AppRatingPromptStorageTests.swift @@ -52,7 +52,8 @@ class AppRatingPromptStorageTests: XCTestCase { private func reset() { let storage = AppRatingPromptCoreDataStorage() - storage.context.delete(storage.ratingPromptEntity()) + guard let ratingPromptEntity = storage.ratingPromptEntity() else { return } + storage.context.delete(ratingPromptEntity) try? storage.context.save() } diff --git a/DuckDuckGoTests/AppRatingPromptTests.swift b/DuckDuckGoTests/AppRatingPromptTests.swift index 582a2ba310..899034329a 100644 --- a/DuckDuckGoTests/AppRatingPromptTests.swift +++ b/DuckDuckGoTests/AppRatingPromptTests.swift @@ -93,9 +93,18 @@ class AppRatingPromptTests: XCTestCase { appRatingPrompt.registerUsage(onDate: Date().inDays(fromNow: 1)) appRatingPrompt.registerUsage(onDate: Date().inDays(fromNow: 2)) XCTAssertTrue(appRatingPrompt.shouldPrompt(onDate: Date().inDays(fromNow: 2))) - } - + + func testWhenUniqueAccessDaysIsNilDueToFetchErrorThenShouldNotPrompt() { + let storage = AppRatingPromptStorageStub() + storage.uniqueAccessDays = nil + let appRatingPrompt = AppRatingPrompt(storage: storage) + appRatingPrompt.registerUsage(onDate: Date().inDays(fromNow: 0)) + appRatingPrompt.registerUsage(onDate: Date().inDays(fromNow: 1)) + appRatingPrompt.registerUsage(onDate: Date().inDays(fromNow: 2)) + XCTAssertFalse(appRatingPrompt.shouldPrompt(onDate: Date().inDays(fromNow: 2))) + } + func testWhenUserAccessSecondUniqueDayThenShouldNotPrompt() { let stub = AppRatingPromptStorageStub() @@ -117,15 +126,14 @@ class AppRatingPromptTests: XCTestCase { func testWhenUserAccessFirstDayThenShouldNotPrompt() { XCTAssertFalse(AppRatingPrompt(storage: AppRatingPromptStorageStub()).shouldPrompt(onDate: Date().inDays(fromNow: 0))) } - } private class AppRatingPromptStorageStub: AppRatingPromptStorage { var lastAccess: Date? - var uniqueAccessDays: Int = 0 - + var uniqueAccessDays: Int? = 0 + var lastShown: Date? } diff --git a/DuckDuckGoTests/BrokenSiteReportingTests.swift b/DuckDuckGoTests/BrokenSiteReportingTests.swift index 3959b8cbf3..838b3478bf 100644 --- a/DuckDuckGoTests/BrokenSiteReportingTests.swift +++ b/DuckDuckGoTests/BrokenSiteReportingTests.swift @@ -23,8 +23,9 @@ import BrowserServicesKit import OHHTTPStubs import OHHTTPStubsSwift @testable import Core - +import PrivacyDashboard @testable import DuckDuckGo +import TestUtils final class BrokenSiteReportingTests: XCTestCase { private let data = JsonTestDataLoader() @@ -52,7 +53,6 @@ final class BrokenSiteReportingTests: XCTestCase { func testBrokenSiteReporting() throws { let testJSON = data.fromJsonFile(Resource.tests) - let testString = String(data: testJSON, encoding: .utf8) let testData = try JSONDecoder().decode(BrokenSiteReportingTestData.self, from: testJSON) referenceTests = testData.reportURL.tests.filter { @@ -61,13 +61,12 @@ final class BrokenSiteReportingTests: XCTestCase { let testsExecuted = expectation(description: "tests executed") testsExecuted.expectedFulfillmentCount = referenceTests.count - - runReferenceTests(onTestExecuted: testsExecuted) - waitForExpectations(timeout: 30, handler: nil) - + + try runReferenceTests(onTestExecuted: testsExecuted) + waitForExpectations(timeout: 10, handler: nil) } - private func runReferenceTests(onTestExecuted: XCTestExpectation) { + private func runReferenceTests(onTestExecuted: XCTestExpectation) throws { guard let test = referenceTests.popLast() else { return @@ -75,56 +74,43 @@ final class BrokenSiteReportingTests: XCTestCase { os_log("Testing [%s]", type: .info, test.name) - let brokenSiteInfo = BrokenSiteInfo(url: URL(string: test.siteURL), - httpsUpgrade: test.wasUpgraded, - blockedTrackerDomains: test.blockedTrackers, - installedSurrogates: test.surrogates, - isDesktop: true, - tdsETag: test.blocklistVersion, - ampUrl: nil, - urlParametersRemoved: false, - protectionsState: test.protectionsEnabled, - model: test.model ?? "", - manufacturer: test.manufacturer ?? "", - systemVersion: test.os ?? "", - gpc: test.gpcEnabled) - - stub(condition: isHost(host)) { request -> HTTPStubsResponse in + let websiteBreakage = WebsiteBreakage(siteUrl: URL(string: test.siteURL)!, + category: test.category, + description: "", + osVersion: test.os ?? "", + manufacturer: test.manufacturer ?? "", + upgradedHttps: test.wasUpgraded, + tdsETag: test.blocklistVersion, + blockedTrackerDomains: test.blockedTrackers, + installedSurrogates: test.surrogates, + isGPCEnabled: test.gpcEnabled ?? false, + ampURL: "", + urlParametersRemoved: false, + protectionsState: test.protectionsEnabled, + reportFlow: .dashboard, + siteType: .mobile, + atb: "", + model: test.model ?? "", + error: nil, + httpStatusCode: nil) + + let reporter = WebsiteBreakageReporter(pixelHandler: { params in - guard let requestURL = request.url else { - XCTFail("Couldn't create request URL") - return HTTPStubsResponse(data: Data(), statusCode: 200, headers: nil) - } - - let absoluteURL = requestURL.absoluteString - .replacingOccurrences(of: "%20", with: " ") - - if test.expectReportURLPrefix.count > 0 { - XCTAssertTrue(requestURL.absoluteString.contains(test.expectReportURLPrefix), "Prefix [\(test.expectReportURLPrefix)] not found") - } - - for param in test.expectReportURLParams { - let pattern = "[?&]\(param.name)=\(param.value)[&$]?" - - guard let regex = try? NSRegularExpression(pattern: pattern, - options: []) else { - XCTFail("Couldn't create regex") - return HTTPStubsResponse(data: Data(), statusCode: 200, headers: nil) + for expectedParam in test.expectReportURLParams { + + if let actualValue = params[expectedParam.name], + let expectedCleanValue = expectedParam.value.removingPercentEncoding { + if actualValue != expectedCleanValue { + XCTFail("Mismatching param: \(expectedParam.name) => \(expectedCleanValue) != \(actualValue)") + } + } else { + XCTFail("Missing param: \(expectedParam.name)") } - - let match = regex.matches(in: absoluteURL, range: NSRange(location: 0, length: absoluteURL.count)) - XCTAssertEqual(match.count, 1, "Param [\(param.name)] with value [\(param.value)] not found in [\(absoluteURL)]") - } - - DispatchQueue.main.async { - onTestExecuted.fulfill() - self.runReferenceTests(onTestExecuted: onTestExecuted) } - - return HTTPStubsResponse(data: Data(), statusCode: 200, headers: nil) - } - - brokenSiteInfo.send(with: test.category, description: "", source: .dashboard) + onTestExecuted.fulfill() + try? self.runReferenceTests(onTestExecuted: onTestExecuted) + }, keyValueStoring: MockKeyValueStore()) + try reporter.report(breakage: websiteBreakage) } } diff --git a/DuckDuckGoTests/CookieStorageTests.swift b/DuckDuckGoTests/CookieStorageTests.swift new file mode 100644 index 0000000000..79b0dc2751 --- /dev/null +++ b/DuckDuckGoTests/CookieStorageTests.swift @@ -0,0 +1,188 @@ +// +// CookieStorageTests.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Core +import WebKit + +public class CookieStorageTests: XCTestCase { + + var storage: CookieStorage! + + // This is updated by the `make` function which preserves any cookies added as part of this test + let logins = PreserveLogins.shared + + static let userDefaultsSuiteName = "test" + + public override func setUp() { + super.setUp() + let defaults = UserDefaults(suiteName: Self.userDefaultsSuiteName)! + defaults.removePersistentDomain(forName: Self.userDefaultsSuiteName) + storage = CookieStorage(userDefaults: defaults) + logins.clearAll() + } + + func testWhenUpdatedThenDuckDuckGoCookiesAreNotRemoved() { + storage.updateCookies([ + make("duckduckgo.com", name: "x", value: "1"), + ], keepingPreservedLogins: logins) + + XCTAssertEqual(1, storage.cookies.count) + + storage.updateCookies([ + make("test.com", name: "x", value: "1"), + ], keepingPreservedLogins: logins) + + XCTAssertEqual(2, storage.cookies.count) + + } + + func testWhenUpdatedThenCookiesWithFutureExpirationAreNotRemoved() { + storage.updateCookies([ + make("test.com", name: "x", value: "1", expires: .distantFuture), + ], keepingPreservedLogins: logins) + + storage.updateCookies([ + make("example.com", name: "x", value: "1"), + ], keepingPreservedLogins: logins) + + XCTAssertEqual(2, storage.cookies.count) + XCTAssertTrue(storage.cookies.contains(where: { $0.domain == "test.com" })) + XCTAssertTrue(storage.cookies.contains(where: { $0.domain == "example.com" })) + + } + + func testWhenUpdatingThenExistingExpiredCookiesAreRemoved() { + storage.cookies = [ + make("test.com", name: "x", value: "1", expires: Date(timeIntervalSinceNow: -100)), + ] + XCTAssertEqual(1, storage.cookies.count) + + storage.updateCookies([ + make("example.com", name: "x", value: "1"), + ], keepingPreservedLogins: logins) + + XCTAssertEqual(1, storage.cookies.count) + XCTAssertFalse(storage.cookies.contains(where: { $0.domain == "test.com" })) + XCTAssertTrue(storage.cookies.contains(where: { $0.domain == "example.com" })) + + } + + func testWhenExpiredCookieIsAddedThenItIsNotPersisted() { + + storage.updateCookies([ + make("example.com", name: "x", value: "1", expires: Date(timeIntervalSinceNow: -100)), + ], keepingPreservedLogins: logins) + + XCTAssertEqual(0, storage.cookies.count) + + } + + func testWhenUpdatedThenNoLongerPreservedDomainsAreCleared() { + storage.updateCookies([ + make("test.com", name: "x", value: "1"), + make("example.com", name: "x", value: "1"), + ], keepingPreservedLogins: logins) + + logins.remove(domain: "test.com") + + storage.updateCookies([ + make("example.com", name: "x", value: "1"), + ], keepingPreservedLogins: logins) + + XCTAssertEqual(1, storage.cookies.count) + XCTAssertFalse(storage.cookies.contains(where: { $0.domain == "test.com" })) + XCTAssertTrue(storage.cookies.contains(where: { $0.domain == "example.com" })) + } + + func testWhenStorageInitialiedThenItIsEmptyAndConsumedIsFalse() { + XCTAssertEqual(0, storage.cookies.count) + XCTAssertEqual(false, storage.isConsumed) + } + + func testWhenStorageIsUpdatedThenConsumedIsResetToFalse() { + storage.isConsumed = true + XCTAssertTrue(storage.isConsumed) + storage.updateCookies([ + make("test.com", name: "x", value: "1") + ], keepingPreservedLogins: logins) + XCTAssertFalse(storage.isConsumed) + } + + func testWhenStorageIsReinstanciatedThenUsesStoredData() { + storage.updateCookies([ + make("test.com", name: "x", value: "1") + ], keepingPreservedLogins: logins) + storage.isConsumed = true + + let otherStorage = CookieStorage(userDefaults: UserDefaults(suiteName: Self.userDefaultsSuiteName)!) + XCTAssertEqual(1, otherStorage.cookies.count) + XCTAssertTrue(otherStorage.isConsumed) + } + + func testWhenStorageIsUpdatedThenUpdatingAddsNewCookies() { + storage.updateCookies([ + make("test.com", name: "x", value: "1") + ], keepingPreservedLogins: logins) + XCTAssertEqual(1, storage.cookies.count) + } + + func testWhenStorageIsUpdatedThenExistingCookiesAreUnaffected() { + storage.updateCookies([ + make("test.com", name: "x", value: "1"), + make("example.com", name: "x", value: "1"), + ], keepingPreservedLogins: logins) + + storage.updateCookies([ + make("example.com", name: "x", value: "2"), + ], keepingPreservedLogins: logins) + + XCTAssertEqual(2, storage.cookies.count) + XCTAssertTrue(storage.cookies.contains(where: { $0.domain == "test.com" && $0.name == "x" && $0.value == "1" })) + XCTAssertTrue(storage.cookies.contains(where: { $0.domain == "example.com" && $0.name == "x" && $0.value == "2" })) + } + + func testWhenStorageHasMatchingDOmainThenUpdatingReplacesCookies() { + storage.updateCookies([ + make("test.com", name: "x", value: "1") + ], keepingPreservedLogins: logins) + + storage.updateCookies([ + make("test.com", name: "x", value: "2"), + make("test.com", name: "y", value: "3"), + ], keepingPreservedLogins: logins) + + XCTAssertEqual(2, storage.cookies.count) + XCTAssertFalse(storage.cookies.contains(where: { $0.domain == "test.com" && $0.name == "x" && $0.value == "1" })) + XCTAssertTrue(storage.cookies.contains(where: { $0.domain == "test.com" && $0.name == "x" && $0.value == "2" })) + XCTAssertTrue(storage.cookies.contains(where: { $0.domain == "test.com" && $0.name == "y" && $0.value == "3" })) + } + + func make(_ domain: String, name: String, value: String, expires: Date? = nil) -> HTTPCookie { + logins.addToAllowed(domain: domain) + return HTTPCookie(properties: [ + .domain: domain, + .name: name, + .value: value, + .path: "/", + .expires: expires as Any + ])! + } + +} diff --git a/DuckDuckGoTests/FireButtonReferenceTests.swift b/DuckDuckGoTests/FireButtonReferenceTests.swift index 2d81f4d26d..2f04f6fbc3 100644 --- a/DuckDuckGoTests/FireButtonReferenceTests.swift +++ b/DuckDuckGoTests/FireButtonReferenceTests.swift @@ -23,9 +23,6 @@ import WebKit @testable import Core final class FireButtonReferenceTests: XCTestCase { - private var referenceTests = [Test]() - private let preservedLogins = PreserveLogins.shared - private let dataStore = WKWebsiteDataStore.default() private enum Resource { static let tests = "privacy-reference-tests/storage-clearing/tests.json" @@ -36,19 +33,6 @@ final class FireButtonReferenceTests: XCTestCase { // swiftlint:disable:next force_try return try! JSONDecoder().decode(TestData.self, from: testJSON) }() - - override func tearDownWithError() throws { - try super.tearDownWithError() - - // Remove fireproofed sites - for site in testData.fireButtonFireproofing.fireproofedSites { - let sanitizedSite = sanitizedSite(site) - os_log("Removing %s from fireproofed sites", sanitizedSite) - PreserveLogins.shared.remove(domain: sanitizedSite) - } - - referenceTests.removeAll() - } private func sanitizedSite(_ site: String) -> String { let url: URL @@ -60,67 +44,42 @@ final class FireButtonReferenceTests: XCTestCase { return url.host! } - func testFireproofing() throws { - // Setup fireproofed sites + func testCookieStorage() { + let preservedLogins = PreserveLogins.shared + preservedLogins.clearAll() + for site in testData.fireButtonFireproofing.fireproofedSites { let sanitizedSite = sanitizedSite(site) os_log("Adding %s to fireproofed sites", sanitizedSite) preservedLogins.addToAllowed(domain: sanitizedSite) - - } - - referenceTests = testData.fireButtonFireproofing.tests.filter { - $0.exceptPlatforms.contains("ios-browser") == false } - let testsExecuted = expectation(description: "tests executed") - testsExecuted.expectedFulfillmentCount = referenceTests.count - - runReferenceTests(onTestExecuted: testsExecuted) - waitForExpectations(timeout: 30, handler: nil) - } - - private func runReferenceTests(onTestExecuted: XCTestExpectation) { - guard let test = referenceTests.popLast() else { - return - } - - guard let cookie = cookie(for: test) else { - XCTFail("Cookie should exist for test \(test.name)") - return + let referenceTests = testData.fireButtonFireproofing.tests.filter { + $0.exceptPlatforms.contains("ios-browser") == false } - - dataStore.cookieStore?.setCookie(cookie, completionHandler: { - let dataStoreIdManager = DataStoreIdManager() - WebCacheManager.shared.clear(logins: self.preservedLogins, dataStoreIdManager: dataStoreIdManager) { + let cookieStorage = CookieStorage() + for test in referenceTests { + guard let cookie = cookie(for: test) else { + XCTFail("Cookie should exist for test \(test.name)") + return + } + + cookieStorage.updateCookies([ + cookie + ], keepingPreservedLogins: preservedLogins) + + let testCookie = cookieStorage.cookies.filter { $0.name == test.cookieName }.first - self.dataStore.cookieStore?.getAllCookies { hotCookies in - let testCookie = hotCookies.filter { $0.name == test.cookieName }.first - - if test.expectCookieRemoved { - XCTAssertNil(testCookie, "Cookie should not exist for test: \(test.name)") - } else { - XCTAssertNotNil(testCookie, "Cookie should exist for test: \(test.name)") - } - - - // Remove all cookies from this test - let group = DispatchGroup() - for cookie in hotCookies { - group.enter() - self.dataStore.cookieStore?.delete(cookie, completionHandler: { - group.leave() - }) - } - - group.notify(queue: .main) { - onTestExecuted.fulfill() - self.runReferenceTests(onTestExecuted: onTestExecuted) - } - } + if test.expectCookieRemoved { + XCTAssertNil(testCookie, "Cookie should not exist for test: \(test.name)") + } else { + XCTAssertNotNil(testCookie, "Cookie should exist for test: \(test.name)") } - }) + + // Reset cache + cookieStorage.cookies = [] + } } private func cookie(for test: Test) -> HTTPCookie? { diff --git a/Gemfile.lock b/Gemfile.lock index d857156940..41d2cd26f5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -32,7 +32,8 @@ GEM declarative (0.0.20) digest-crc (0.6.5) rake (>= 12.0.0, < 14.0.0) - domain_name (0.6.20231109) + domain_name (0.5.20190701) + unf (>= 0.0.5, < 1.0.0) dotenv (2.8.1) emoji_regex (3.2.3) excon (0.104.0) @@ -188,6 +189,9 @@ GEM tty-spinner (0.9.3) tty-cursor (~> 0.7) uber (0.1.0) + unf (0.1.4) + unf_ext + unf_ext (0.0.9.1) unicode-display_width (2.5.0) webrick (1.8.1) word_wrap (1.0.0) diff --git a/LocalPackages/DuckUI/Package.swift b/LocalPackages/DuckUI/Package.swift index 74c51b6864..f157cebcee 100644 --- a/LocalPackages/DuckUI/Package.swift +++ b/LocalPackages/DuckUI/Package.swift @@ -31,7 +31,7 @@ let package = Package( targets: ["DuckUI"]) ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "101.2.2"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "104.1.1"), ], targets: [ .target( diff --git a/LocalPackages/SyncUI/Package.swift b/LocalPackages/SyncUI/Package.swift index b7c6f13b13..b56372728e 100644 --- a/LocalPackages/SyncUI/Package.swift +++ b/LocalPackages/SyncUI/Package.swift @@ -33,7 +33,7 @@ let package = Package( ], dependencies: [ .package(path: "../DuckUI"), - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "101.2.2"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "104.1.1"), .package(url: "https://github.com/duckduckgo/DesignResourcesKit", exact: "2.0.0") ], targets: [ diff --git a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/SyncSettingsViewModel.swift b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/SyncSettingsViewModel.swift index 6934aaae01..a51474ebc3 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/SyncSettingsViewModel.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/SyncSettingsViewModel.swift @@ -88,6 +88,7 @@ public class SyncSettingsViewModel: ObservableObject { @Published public var isConnectingDevicesAvailable: Bool = true @Published public var isAccountCreationAvailable: Bool = true @Published public var isAccountRecoveryAvailable: Bool = true + @Published public var isAppVersionNotSupported: Bool = false public weak var delegate: SyncManagementViewModelDelegate? private(set) var isOnDevEnvironment: Bool diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/SyncSettingsViewExtension.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/SyncSettingsViewExtension.swift index b4f5c9e990..95922b61a5 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/SyncSettingsViewExtension.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/SyncSettingsViewExtension.swift @@ -26,7 +26,11 @@ extension SyncSettingsView { @ViewBuilder func syncUnavailableViewWhileLoggedOut() -> some View { if !model.isDataSyncingAvailable || !model.isConnectingDevicesAvailable || !model.isAccountCreationAvailable { - SyncWarningMessageView(title: UserText.syncUnavailableTitle, message: UserText.syncUnavailableMessage) + if model.isAppVersionNotSupported { + SyncWarningMessageView(title: UserText.syncUnavailableTitle, message: UserText.syncUnavailableMessageUpgradeRequired) + } else { + SyncWarningMessageView(title: UserText.syncUnavailableTitle, message: UserText.syncUnavailableMessage) + } } else { EmptyView() } @@ -105,7 +109,11 @@ extension SyncSettingsView { if model.isDataSyncingAvailable { EmptyView() } else { - SyncWarningMessageView(title: UserText.syncPausedTitle, message: UserText.syncUnavailableMessage) + if model.isAppVersionNotSupported { + SyncWarningMessageView(title: UserText.syncUnavailableTitle, message: UserText.syncUnavailableMessageUpgradeRequired) + } else { + SyncWarningMessageView(title: UserText.syncUnavailableTitle, message: UserText.syncUnavailableMessage) + } } } @@ -306,13 +314,15 @@ extension SyncSettingsView { @ViewBuilder func rolloutBanner() -> some View { - HStack(alignment: .top, spacing: 16) { - Image("Info-Color-16") - Text(UserText.syncRollOutBannerDescription) - .font(.system(size: 12)) - .foregroundColor(.primary) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) + Section { + HStack(alignment: .top, spacing: 16) { + Image("Info-Color-16") + Text(UserText.syncRollOutBannerDescription) + .font(.system(size: 12)) + .foregroundColor(.primary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } } .padding() .background(RoundedRectangle(cornerRadius: 8).foregroundColor(Color("RolloutBannerBackground"))) diff --git a/LocalPackages/Waitlist/Package.swift b/LocalPackages/Waitlist/Package.swift index 3988db42e0..7fcae24082 100644 --- a/LocalPackages/Waitlist/Package.swift +++ b/LocalPackages/Waitlist/Package.swift @@ -15,7 +15,7 @@ let package = Package( targets: ["Waitlist", "WaitlistMocks"]) ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "101.2.2"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "104.1.1"), .package(url: "https://github.com/duckduckgo/DesignResourcesKit", exact: "2.0.0") ], targets: [ diff --git a/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift b/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift index 180342ab9c..f127d65a7d 100644 --- a/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift +++ b/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift @@ -140,6 +140,10 @@ final class NetworkProtectionPacketTunnelProvider: PacketTunnelProvider { pixelEvent = .networkProtectionKeychainWriteError params[PixelParameters.keychainFieldName] = field params[PixelParameters.keychainErrorCode] = String(status) + case .keychainUpdateError(let field, let status): + pixelEvent = .networkProtectionKeychainUpdateError + params[PixelParameters.keychainFieldName] = field + params[PixelParameters.keychainErrorCode] = String(status) case .keychainDeleteError(let status): // TODO: Check whether field needed here pixelEvent = .networkProtectionKeychainDeleteError params[PixelParameters.keychainErrorCode] = String(status) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 841515862f..751b5b0df9 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -140,9 +140,23 @@ lane :release_alpha do |options| ) end +desc 'Latest build number for version' +lane :latest_build_number_for_version do |options| + if options[:app_identifier] + app_identifier = options[:app_identifier] + end + build_number = latest_testflight_build_number( + api_key: get_api_key, + version: options[:version], + initial_build_number: -1, + username: get_username(options)) + if options[:file_name] + File.write(options[:file_name], build_number) + end +end + desc 'Increment build number based on version in App Store Connect' lane :increment_build_number_for_version do |options| - app_identifier = "com.duckduckgo.mobile.ios" if options[:app_identifier] app_identifier = options[:app_identifier] end diff --git a/fastlane/README.md b/fastlane/README.md index 5ca5e832ea..ebb133c3c9 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -77,6 +77,14 @@ Makes App Store release build and uploads it to TestFlight Makes Alpha release build and uploads it to TestFlight +### latest_build_number_for_version + +```sh +[bundle exec] fastlane latest_build_number_for_version +``` + +Latest build number for version + ### increment_build_number_for_version ```sh diff --git a/fastlane/metadata/default/release_notes.txt b/fastlane/metadata/default/release_notes.txt index e0c7a0d1ed..fe8f05ed3b 100644 --- a/fastlane/metadata/default/release_notes.txt +++ b/fastlane/metadata/default/release_notes.txt @@ -1,3 +1,2 @@ -- You can now pull the page down to reload it. - Bug fixes and other improvements. Join our fully distributed team and help raise the standard of trust online! https://duckduckgo.com/hiring diff --git a/scripts/prepare_release.sh b/scripts/prepare_release.sh index cfc87c38dc..4a5bde66d3 100755 --- a/scripts/prepare_release.sh +++ b/scripts/prepare_release.sh @@ -3,10 +3,8 @@ set -eo pipefail mute=">/dev/null 2>&1" -version="$1" release_branch_parent="main" -tag=${version} -hotfix_branch_parent="tags/${tag}" +build_number=0 # Get the directory where the script is stored script_dir=$(dirname "$(readlink -f "$0")") @@ -16,6 +14,7 @@ base_dir="${script_dir}/.." # Output passed arguments to stderr and exit. # die() { + echo "" cat >&2 <<< "$*" exit 1 } @@ -51,51 +50,38 @@ print_usage_and_exit() { cat <<- EOF Usage: - $ $(basename "$0") [-h] [-v] + $ $(basename "$0") [-v] Current version: $(cut -d' ' -f3 < "${base_dir}/Configuration/Version.xcconfig") Options: - -h Make hotfix release. Requires the version to be the one to hotfix, and a branch with the fix as the second parameter - -c Make coldfix release (i.e. a new build number on an existing release). Requires the version to be the one to coldfix, and a branch with the fix as the second parameter -v Enable verbose mode + Arguments: + Specify either a version number or a hotfix branch name. + EOF die "${reason}" } read_command_line_arguments() { - number_of_arguments="$#" + local input="$1" + local version_regexp="^[0-9]+(\.[0-9]+)*$" - local regexp="^[0-9]+(\.[0-9]+)*$" - if [[ ! "$1" =~ $regexp ]]; then - print_usage_and_exit "💥 Error: Wrong app version specified" + if [ -z "$input" ]; then + print_usage_and_exit "💥 Error: Missing argument" fi - if [[ "$#" -ne 1 ]]; then - if [[ "$2" == -* ]]; then - shift 1 - else - fix_branch=$2 - shift 2 - fi + if [[ $input =~ $version_regexp ]]; then + process_release "$input" + else + process_hotfix "$input" fi - branch_name="release" + shift 1 - while getopts 'hcv' option; do + while getopts 'v' option; do case "${option}" in - h) - is_hotfix=1 - branch_name="hotfix" - fix_type_name="hotfix" - ;; - c) - is_hotfix=1 - is_coldfix=1 - branch_name="coldfix" - fix_type_name="coldfix" - ;; v) mute= ;; @@ -104,24 +90,37 @@ read_command_line_arguments() { ;; esac done +} - shift $((OPTIND-1)) +process_release() { + version="$1" + release_branch="release/${version}" - if [[ $is_hotfix ]]; then - if [[ $number_of_arguments -ne 3 ]]; then - print_usage_and_exit "💥 Error: Wrong number of arguments. Did you specify a fix branch?" - fi + echo "Processing version number: $version" - version_to_hotfix=${version} - if ! [[ $is_coldfix ]]; then - IFS='.' read -ra arrIN <<< "$version" - patch_number=$((arrIN[2] + 1)) - version="${arrIN[0]}.${arrIN[1]}.$patch_number" - fi + if release_branch_exists; then + is_subsequent_release=1 + fi +} + +process_hotfix() { + local input="$1" + echo "Processing hotfix branch name: $input" + + is_hotfix=1 + release_branch="$input" + + if ! release_branch_exists; then + die "💥 Error: Hotfix branch ${release_branch} does not exist" fi +} - release_branch="${branch_name}/${version}" - changes_branch="${release_branch}-changes" +release_branch_exists() { + if git show-ref --verify --quiet "refs/heads/${release_branch}"; then + return 0 + else + return 1 + fi } stash() { @@ -130,51 +129,53 @@ stash() { echo "✅" } -assert_clean_state() { - if git show-ref --quiet "refs/heads/${release_branch}"; then - die "💥 Error: Branch ${release_branch} already exists" - fi - if git show-ref --quiet "refs/heads/${changes_branch}"; then - die "💥 Error: Branch ${changes_branch} already exists" - fi -} +create_release_branch() { + printf '%s' "Creating release branch ... " + eval git checkout "${release_branch_parent}" "$mute" + eval git pull "$mute" -assert_hotfix_tag_exists_if_necessary() { - if [[ ! $is_hotfix ]]; then - return + if [[ ! $is_subsequent_release && ! $is_hotfix ]]; then + if git show-ref --quiet "refs/heads/${release_branch}"; then + die "💥 Error: Branch ${release_branch} already exists" + fi fi - printf '%s' "Checking tag to ${fix_type_name} ... " - # Make sure tag is available locally if it exists - eval git fetch origin "+refs/tags/${tag}:refs/tags/${tag}" "$mute" - - if [[ $(git tag -l "$version_to_hotfix" "$mute") ]]; then - echo "✅" - else - die "💥 Error: Tag ${version_to_hotfix} does not exist" - fi + eval git checkout -b "${release_branch}" "$mute" + eval git push -u origin "${release_branch}" "$mute" + echo "✅" } -create_release_branch() { - if [[ ${is_hotfix} ]]; then - printf '%s' "Creating ${fix_type_name} branch ... " +create_build_branch() { + printf '%s' "Creating build branch ... " + eval git checkout "${release_branch}" "$mute" + eval git pull "$mute" - eval git checkout "${hotfix_branch_parent}" "$mute" - else - printf '%s' "Creating release branch ... " - eval git checkout ${release_branch_parent} "$mute" - eval git pull "$mute" + local temp_file + local latest_build_number + + temp_file=$(mktemp) + bundle exec fastlane latest_build_number_for_version version:"$version" file_name:"$temp_file" + latest_build_number="$(<"$temp_file")" + build_number=$((latest_build_number + 1)) + build_branch="${release_branch}-build-${build_number}" + + if git show-ref --quiet "refs/heads/${build_branch}"; then + die "💥 Error: Branch ${build_branch} already exists" fi - eval git checkout -b "${release_branch}" "$mute" - eval git checkout -b "${changes_branch}" "$mute" + + eval git checkout -b "${build_branch}" "$mute" + eval git push -u origin "${build_branch}" "$mute" echo "✅" } update_marketing_version() { - if [[ $is_coldfix ]]; then - return - fi printf '%s' "Setting app version ... " + + if [[ $is_hotfix ]]; then + version=$(cut -d' ' -f3 < "${base_dir}/Configuration/Version.xcconfig") + version=$(bump_patch_number "$version") + fi + "$script_dir/set_version.sh" "${version}" git add "${base_dir}/Configuration/Version.xcconfig" \ "${base_dir}/DuckDuckGo/Settings.bundle/Root.plist" @@ -182,11 +183,15 @@ update_marketing_version() { echo "✅" } +bump_patch_number() { + IFS='.' read -ra arrIN <<< "$1" + local patch_number=$((arrIN[2] + 1)) + echo "${arrIN[0]}.${arrIN[1]}.$patch_number" +} + update_build_version() { echo "Setting build version ..." - local username - username="$(git config user.email 2>&1)" - (cd "$base_dir" && bundle exec fastlane increment_build_number_for_version version:"${version}" username:"$username") + (cd "$base_dir" && bundle exec fastlane increment_build_number_for_version version:"${version}") git add "${base_dir}/DuckDuckGo.xcodeproj/project.pbxproj" if [[ "$(git diff --cached)" ]]; then eval git commit -m \"Update build number\" "$mute" @@ -225,25 +230,10 @@ update_release_notes() { fi } -merge_fix_branch_if_necessary() { - if [[ ! $is_hotfix ]]; then - return - fi - - printf '%s' "Merging fix branch ... " - eval git checkout "${fix_branch}" "$mute" - eval git pull "$mute" - - eval git checkout "${changes_branch}" "$mute" - eval git merge "${fix_branch}" "$mute" - echo "✅" -} - create_pull_request() { printf '%s' "Creating PR ... " - eval git push origin "${release_branch}" "$mute" - eval git push origin "${changes_branch}" "$mute" - eval gh pr create --title \"Release "${version}"\" --base "${release_branch}" --assignee @me "$mute" --body-file "${script_dir}/assets/prepare-release-description" + eval git push origin "${build_branch}" "$mute" + eval gh pr create --title \"Release "${version}-${build_number}"\" --base "${release_branch}" --label \"Merge triggers release\" --assignee @me "$mute" --body-file "${script_dir}/assets/prepare-release-description" eval gh pr view --web "$mute" echo "✅" } @@ -254,22 +244,23 @@ main() { assert_gh_installed_and_authenticated read_command_line_arguments "$@" - stash - assert_clean_state - assert_hotfix_tag_exists_if_necessary - - create_release_branch - update_marketing_version - update_build_version - if ! [[ $is_hotfix ]]; then + if [[ $is_subsequent_release ]]; then + create_build_branch + elif [[ $is_hotfix ]]; then + create_build_branch + update_marketing_version + else # regular release + create_release_branch + create_build_branch + update_marketing_version update_embedded_files fi - update_release_notes - merge_fix_branch_if_necessary + update_build_version + update_release_notes create_pull_request } -main "$@" +main "$@" \ No newline at end of file diff --git a/submodules/privacy-reference-tests b/submodules/privacy-reference-tests index a3acc21947..6b7ad1e7f1 160000 --- a/submodules/privacy-reference-tests +++ b/submodules/privacy-reference-tests @@ -1 +1 @@ -Subproject commit a3acc2194758bec0f01f57dd0c5f106de01a354e +Subproject commit 6b7ad1e7f15270f9dfeb58a272199f4d57c3eb22