From e9bd56dca465c05a6cec74c233c9cdc1032771da Mon Sep 17 00:00:00 2001 From: Dominik Kapusta <dkapusta@duckduckgo.com> Date: Fri, 15 Mar 2024 06:54:37 +0100 Subject: [PATCH 01/16] Fix determining if internal release should be automatically bumped --- .github/workflows/bump_internal_release.yml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/bump_internal_release.yml b/.github/workflows/bump_internal_release.yml index ba06acdcc6..1b9c4b3c4e 100644 --- a/.github/workflows/bump_internal_release.yml +++ b/.github/workflows/bump_internal_release.yml @@ -63,13 +63,21 @@ jobs: echo "skip-release=false" >> $GITHUB_OUTPUT else latest_tag="$(git describe --tags --abbrev=0)" - changed_files="$(git diff --name-only "$latest_tag".."origin/${release_branch}")" + latest_tag_sha="$(git rev-parse "$latest_tag")" + release_branch_sha="$(git rev-parse "origin/${release_branch}")" - if grep -q -v -e '.github' -e 'scripts' <<< "$changed_files"; then - echo "skip-release=false" >> $GITHUB_OUTPUT - else - echo "::warning::No changes to the release branch (or only changes to scripts and workflows). Skipping automatic release." + if [[ "${latest_tag_sha}" == "${release_branch_sha}" ]]; then + echo "::warning::Release branch's HEAD is already tagged. Skipping automatic release." echo "skip-release=true" >> $GITHUB_OUTPUT + else + changed_files="$(git diff --name-only "$latest_tag".."origin/${release_branch}")" + if grep -q -v -e '.github' -e 'scripts' <<< "$changed_files"; then + echo "::warning::New code changes found in the release branch since the last release. Will bump internal release now." + echo "skip-release=false" >> $GITHUB_OUTPUT + else + echo "::warning::No changes to the release branch (or only changes to scripts and workflows). Skipping automatic release." + echo "skip-release=true" >> $GITHUB_OUTPUT + fi fi fi From a40035b73906caff097f53471521d274de2696fe Mon Sep 17 00:00:00 2001 From: Juan Manuel Pereira <juanmanuel.pereira1@gmail.com> Date: Fri, 15 Mar 2024 08:38:32 -0300 Subject: [PATCH 02/16] DBP: Use url instead of name to identify brokers on pixels (#2423) --- .../Operations/DataBrokerProfileQueryOperationManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerProfileQueryOperationManager.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerProfileQueryOperationManager.swift index ff931a7687..58352c3fc3 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerProfileQueryOperationManager.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerProfileQueryOperationManager.swift @@ -117,7 +117,7 @@ struct DataBrokerProfileQueryOperationManager: OperationsManager { notificationCenter.post(name: DataBrokerProtectionNotifications.didFinishScan, object: brokerProfileQueryData.dataBroker.name) } - let stageCalculator = DataBrokerProtectionStageDurationCalculator(dataBroker: brokerProfileQueryData.dataBroker.name, handler: pixelHandler) + let stageCalculator = DataBrokerProtectionStageDurationCalculator(dataBroker: brokerProfileQueryData.dataBroker.url, handler: pixelHandler) do { let event = HistoryEvent(brokerId: brokerId, profileQueryId: profileQueryId, type: .scanStarted) @@ -280,7 +280,7 @@ struct DataBrokerProfileQueryOperationManager: OperationsManager { } let retriesCalculatorUseCase = OperationRetriesCalculatorUseCase() - let stageDurationCalculator = DataBrokerProtectionStageDurationCalculator(dataBroker: brokerProfileQueryData.dataBroker.name, handler: pixelHandler) + let stageDurationCalculator = DataBrokerProtectionStageDurationCalculator(dataBroker: brokerProfileQueryData.dataBroker.url, handler: pixelHandler) stageDurationCalculator.fireOptOutStart() os_log("Running opt-out operation: %{public}@", log: .dataBrokerProtection, String(describing: brokerProfileQueryData.dataBroker.name)) From 919a93baf974e56a9b69b440b2b3918757bc7644 Mon Sep 17 00:00:00 2001 From: Juan Manuel Pereira <juanmanuel.pereira1@gmail.com> Date: Fri, 15 Mar 2024 08:39:00 -0300 Subject: [PATCH 03/16] DBP: Treat 404 as failure instead of error (#2426) --- ...kerProtectionStageDurationCalculator.swift | 7 +- ...otectionStageDurationCalculatorTests.swift | 141 ++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionStageDurationCalculatorTests.swift diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionStageDurationCalculator.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionStageDurationCalculator.swift index 7de439e30b..36723e14bf 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionStageDurationCalculator.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionStageDurationCalculator.swift @@ -170,7 +170,12 @@ final class DataBrokerProtectionStageDurationCalculator: StageDurationCalculator switch dataBrokerProtectionError { case .httpError(let httpCode): if httpCode < 500 { - errorCategory = .clientError(httpCode: httpCode) + if httpCode == 404 { + fireScanFailed() + return + } else { + errorCategory = .clientError(httpCode: httpCode) + } } else { errorCategory = .serverError(httpCode: httpCode) } diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionStageDurationCalculatorTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionStageDurationCalculatorTests.swift new file mode 100644 index 0000000000..8b899e12a1 --- /dev/null +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionStageDurationCalculatorTests.swift @@ -0,0 +1,141 @@ +// +// DataBrokerProtectionStageDurationCalculatorTests.swift +// +// Copyright © 2023 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 BrowserServicesKit +import Foundation +import XCTest + +@testable import DataBrokerProtection + +final class DataBrokerProtectionStageDurationCalculatorTests: XCTestCase { + let handler = MockDataBrokerProtectionPixelsHandler() + + override func tearDown() { + handler.clear() + } + + func testWhenErrorIs404_thenWeFireScanFailedPixel() { + let sut = DataBrokerProtectionStageDurationCalculator(dataBroker: "broker", handler: handler) + + sut.fireScanError(error: DataBrokerProtectionError.httpError(code: 404)) + + XCTAssertTrue(MockDataBrokerProtectionPixelsHandler.lastPixelsFired.count == 1) + + if let failurePixel = MockDataBrokerProtectionPixelsHandler.lastPixelsFired.last{ + switch failurePixel { + case .scanFailed(let broker, _, _): + XCTAssertEqual(broker, "broker") + default: XCTFail("The scan failed pixel should be fired") + } + } else { + XCTFail("A pixel should be fired") + } + } + + func testWhenErrorIs403_thenWeFireScanErrorPixelWithClientErrorCategory() { + let sut = DataBrokerProtectionStageDurationCalculator(dataBroker: "broker", handler: handler) + + sut.fireScanError(error: DataBrokerProtectionError.httpError(code: 403)) + + XCTAssertTrue(MockDataBrokerProtectionPixelsHandler.lastPixelsFired.count == 1) + + if let failurePixel = MockDataBrokerProtectionPixelsHandler.lastPixelsFired.last{ + switch failurePixel { + case .scanError(_, _, let category, _): + XCTAssertEqual(category, ErrorCategory.clientError(httpCode: 403).toString) + default: XCTFail("The scan error pixel should be fired") + } + } else { + XCTFail("A pixel should be fired") + } + } + + func testWhenErrorIs500_thenWeFireScanErrorPixelWithServerErrorCategory() { + let sut = DataBrokerProtectionStageDurationCalculator(dataBroker: "broker", handler: handler) + + sut.fireScanError(error: DataBrokerProtectionError.httpError(code: 500)) + + XCTAssertTrue(MockDataBrokerProtectionPixelsHandler.lastPixelsFired.count == 1) + + if let failurePixel = MockDataBrokerProtectionPixelsHandler.lastPixelsFired.last{ + switch failurePixel { + case .scanError(_, _, let category, _): + XCTAssertEqual(category, ErrorCategory.serverError(httpCode: 500).toString) + default: XCTFail("The scan error pixel should be fired") + } + } else { + XCTFail("A pixel should be fired") + } + } + + func testWhenErrorIsNotHttp_thenWeFireScanErrorPixelWithValidationErrorCategory() { + let sut = DataBrokerProtectionStageDurationCalculator(dataBroker: "broker", handler: handler) + + sut.fireScanError(error: DataBrokerProtectionError.actionFailed(actionID: "Action-ID", message: "Some message")) + + XCTAssertTrue(MockDataBrokerProtectionPixelsHandler.lastPixelsFired.count == 1) + + if let failurePixel = MockDataBrokerProtectionPixelsHandler.lastPixelsFired.last{ + switch failurePixel { + case .scanError(_, _, let category, _): + XCTAssertEqual(category, ErrorCategory.validationError.toString) + default: XCTFail("The scan error pixel should be fired") + } + } else { + XCTFail("A pixel should be fired") + } + } + + func testWhenErrorIsNotDBPErrorButItIsNSURL_thenWeFireScanErrorPixelWithNetworkErrorErrorCategory() { + let sut = DataBrokerProtectionStageDurationCalculator(dataBroker: "broker", handler: handler) + let nsURLError = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) + + sut.fireScanError(error: nsURLError) + + XCTAssertTrue(MockDataBrokerProtectionPixelsHandler.lastPixelsFired.count == 1) + + if let failurePixel = MockDataBrokerProtectionPixelsHandler.lastPixelsFired.last{ + switch failurePixel { + case .scanError(_, _, let category, _): + XCTAssertEqual(category, ErrorCategory.networkError.toString) + default: XCTFail("The scan error pixel should be fired") + } + } else { + XCTFail("A pixel should be fired") + } + } + + func testWhenErrorIsNotDBPErrorAndNotURL_thenWeFireScanErrorPixelWithUnclassifiedErrorCategory() { + let sut = DataBrokerProtectionStageDurationCalculator(dataBroker: "broker", handler: handler) + let error = NSError(domain: NSCocoaErrorDomain, code: -1) + + sut.fireScanError(error: error) + + XCTAssertTrue(MockDataBrokerProtectionPixelsHandler.lastPixelsFired.count == 1) + + if let failurePixel = MockDataBrokerProtectionPixelsHandler.lastPixelsFired.last{ + switch failurePixel { + case .scanError(_, _, let category, _): + XCTAssertEqual(category, ErrorCategory.unclassified.toString) + default: XCTFail("The scan error pixel should be fired") + } + } else { + XCTFail("A pixel should be fired") + } + } +} From aaacc14afca2f61dd36314bb09f6837b344713bb Mon Sep 17 00:00:00 2001 From: Michal Smaga <miasma13@gmail.com> Date: Fri, 15 Mar 2024 12:49:25 +0100 Subject: [PATCH 04/16] Properly handle edge cases (#2417) Task/Issue URL: https://app.asana.com/0/0/1206841322374471/f **Description**: - Handle edge cases via "Something Went Wrong" alert - Handle scenario when App Store purchase went through but the BE failed - Fix description in the settings detailing subscription's billing period and expiry or renewal date - Handle expired subscription state **Steps to test this PR**: Test triggering "Something Went Wrong" alert, e.g.: 1. Opening purchase page 2. Disable internet connection 3. Try purchasing Handle scenario when App Store purchase went through but the BE failed 1. Modify `SubscriptionPagesUserScript.swift` commenting call to `AppStorePurchaseFlow.completeSubscriptionPurchase` and the whole `switch` - but you need to leave `await pushPurchaseUpdate(originalMessage: message, purchaseUpdate: PurchaseUpdate(type: "completed"))` 2. Switch purchase platform to App Store 3. Try purchasing subscription 5. Due to code modification the confirmation endpoint will not be used leaving you with an account lacking entitlements 6. Go to settings Fix description in the settings detailing subscription's billing period and expiry or renewal date 1. Purchase or activate subscription 2. Open settings, the description should properly reflect if subscription will renew or expire at given date Handle expired subscription state 1. Purchase subscription via App Store 3. Wait until it expires 4. Open settings <!-- Tagging instructions If this PR isn't ready to be merged for whatever reason it should be marked with the `DO NOT MERGE` label (particularly if it's a draft) If it's pending Product Review/PFR, please add the `Pending Product Review` label. If at any point it isn't actively being worked on/ready for review/otherwise moving forward (besides the above PR/PFR exception) strongly consider closing it (or not opening it in the first place). If you decide not to close it, make sure it's labelled to make it clear the PRs state and comment with more information. --> --- ###### Internal references: [Pull Request Review Checklist](https://app.asana.com/0/1202500774821704/1203764234894239/f) [Software Engineering Expectations](https://app.asana.com/0/59792373528535/199064865822552) [Technical Design Template](https://app.asana.com/0/59792373528535/184709971311943) [Pull Request Documentation](https://app.asana.com/0/1202500774821704/1204012835277482/f) --- .../Images/ITR-Icon.imageset/ITR-Icon.pdf | Bin 4129 -> 3919 bytes .../SubscriptionIcon.pdf | Bin 2646 -> 4808 bytes DuckDuckGo/Common/Localizables/UserText.swift | 1 - .../NavigationBar/View/MoreOptionsMenu.swift | 9 +- .../SubscriptionPagesUserScript.swift | 12 +- .../SubscriptionUI/NSAlert+Subscription.swift | 8 - .../PreferencesSubscriptionModel.swift | 115 ++++-- .../PreferencesSubscriptionView.swift | 352 ++++++++++++------ .../Contents.json | 12 + .../subscription-expired-icon.pdf | Bin 0 -> 2374 bytes .../Contents.json | 12 + .../Info-Color-16.pdf | Bin 0 -> 1745 bytes .../ActivateSubscriptionAccessModel.swift | 5 +- .../Model/ShareSubscriptionAccessModel.swift | 2 +- .../SubscriptionAccessView.swift | 1 + .../Sources/SubscriptionUI/UserText.swift | 55 ++- 16 files changed, 407 insertions(+), 177 deletions(-) create mode 100644 LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/subscription-expired-icon.imageset/Contents.json create mode 100644 LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/subscription-expired-icon.imageset/subscription-expired-icon.pdf create mode 100644 LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/subscription-pending-icon.imageset/Contents.json create mode 100644 LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/subscription-pending-icon.imageset/Info-Color-16.pdf diff --git a/DuckDuckGo/Assets.xcassets/Images/ITR-Icon.imageset/ITR-Icon.pdf b/DuckDuckGo/Assets.xcassets/Images/ITR-Icon.imageset/ITR-Icon.pdf index 1ff6a4f6db7f5eb49a1d732bf7d6ebef1fbc4c10..71e908133d48e1c57639464825a9722d736aefbe 100644 GIT binary patch delta 143 zcmZ3ea9(bL_{6h*o0S;NStlzo>Tm96>tX~l^fvo(D6xP=mhr?hG8>s&Z06uAWb`vI tHc>DD0fjs+Fl}ILY-WKWWNC;lWNL<?(ad=AasEI~GfQ(WRaIAiHvlNmAr=4t delta 353 zcmX>vw@_h%c)h-BMTvWGNn%N=f{l%WzN-nCN={TT(6cZx00IR&J1zxGpzmr#v`S;# zD$P(#)^|0;trVfs&TgWZKO0DKdhz7htOgsWaIgZoMnLZ7*{r)5fox-ll>X*sb|n@d z-vp|3^A+xRMrLCJv(09_g^YeihGq&NPZ}uXae-+AV^ebrbRjbXLv$f?a}#tS3zNxT Q`2smD4a~VzRbBnv02LET*Z=?k diff --git a/DuckDuckGo/Assets.xcassets/Images/SubscriptionIcon.imageset/SubscriptionIcon.pdf b/DuckDuckGo/Assets.xcassets/Images/SubscriptionIcon.imageset/SubscriptionIcon.pdf index 934a29304855a8da122da590fa025cc53f443972..d9be8eca8e50aae5d9b27cf4e2e48715bd2c614e 100644 GIT binary patch literal 4808 zcmd^@OOG2z5`^#lEBYe9UO>%yKLA63^@QOytex3|0b>soMN4DPkXn*j$ouQ}i|S^R zoYBJa*}SMGx-u&(GBT@5k6u52`PdG*>uhiO?VDe_zW?T%e(}%WKdk%3Pd~o-b-g-( z@mu_Ty?J+d-#aE=P0*{|_Tz^$gZB6Mt=Wu**ZA8lH~SCE-Fmb7{o?zRCzGq|k8cib z(U;rZ-Gp7O_uG%V)q1Zb=9y2QZa?)u_hvBdcN*AqgifP>;R?(BVYgo1b&Kcg|J<zB zSFfJ-&#uq^TJ5`kclKyLyM8>6=5K<m$-(ty@Fiti`_)}H&wDu7uQpw2HpTUR@F}=h z`a3WmV@>9Sl_B|<+bmN;@QEcR#0}Y3*{!gfFf-VkGT3cr?O<zhm|lW0_`nJ?$h_jb z=CKWHyh)+Iz(OdFt*zwoG&5HpoYBn5A(AoKYbMyV@(DYO@dVzU0)On=7O=hqYcbVX z@j4RoI1DaLREu+kJwvN54=KfD!Wo!JNn?;DCU57Eh-<q$f%8UgyD;zg0t~rWEi!{) z-mw`bX3n9R!gFxqIt-JyIV1C;Gop=6yO^=t6E-X14D-XJmzk1TXAhOP>fU$1berxU ze_rttjrA_tFOYm1V$8{-kcetbFb>V9q552M1FQ2PraliL)l^bX&03dSsR+QBg4{<a zR75IC#gI*{Bj^*=Q4wLT#%qaci_HNjpq-lAs@_#6Q%l&<h7@}%x`V5%M($ElV!=X) z#(>$uqjV}7%taH31#34LXANZpYGQ2RlZC{R&7-m2R7M^(<Qxf`%m-iDCe~q47^)t8 zS{oG`QRGsCZ60&>krm_MDJjDUKQTt1WgV&eQZupnA$aRR)!S^07R*6sg5`!e<3xp1 zSE@msLNlYQHo#~MH7dwBgz6L|-%>6C3*ur0YkCI7kO7$@DG~)y4n~W?hbrBaL>?`n zlvpfmI~#*7G#DmT{I9x7SAyUm%x8l)5N}vth>fP%nNn=)RuE2Rnw^1JBVVLG#Xxf) z!F1RxOR(VmghhI2!^nWzUs2Y_I7~)64pn6`mgUWmAL!5Q*Ok=#y9^tw2?o0^%a^ao zTYN<}PS;_-V2RH#P)k$>p|%Eu#HZ{S`XghKi5a3R#hZy(XwpgHt@jiCp{Nln8tP|E zQ(|kfM0_Hu<ujJ#nMaSd#M9C)`2l5>9_&jWA$o0qlmr@M6Re@yiegI@gQhL)Ds;QS z;cQH`4OF7|_9#4M*VL+*Ok_94<fEulz%x+tq582RQZ%ieqS`VFm2^Ua%FxFF(RtHW zEfh4G{7eH->rrt>#~xJ$k!p+d1=5uZ-Y2kTPrgu?T0ZkQ=ET2R1yr#|!U~M(L$s8X za)MiUVP!uCQ))?#D(|gLmiP=F#_BTBBR)(iQ*{8Uf^yC%EpZmZS(d5#EPey*A)Hp2 zZYXpQ8i9x}0Zq4htN2hg4#}mYqGle=Ls_BS(`8y_spG)<G9{*3C}f8Pb-?<A)8__J zo{roKa)tiLEYBfN`N-6f6k^vMfTGm2TuDHL?T{>;f^aj(DBUq2+Z<>u$~B%0<-@T+ zj{=Jj$mS*Pkb`Bwq2st<DCpFx8ao|Ccx6i@qY|r9KtDzWATe7MLY}N^J&^(k%3M2{ zSw|gHQBkebRi;-3L;^-ixVf<jt|A-^LfAYaqfK#8iW+>e=c(_bVx1#k$#OqXy+`!S zQo^!mov@-FFN}#J7E5V+ie$uT9*sX%+KM_Gjg9UjifyL96!-~-N+e+jA(&+Ik;c{( zX^^DY`H^ipR}*J35y7;SptS~Rqf_co*kqd#gZc`TbCny5HgOE-IFuABty<Q|ifDzY z_cx`2s^8G5Kno-}_KAGenA=P=h%^;;jm1#|n#TZ-$c8cfsx-CYvsE7oM)RsK)*%e7 z%@xO{0!{caoJy!4YfevYf65&4p*gme?jy|M5NV-Tj&B_?B`9|)PI%J!MPqg>LO3eo zXz3Ya*2lg>Tjjb2^q?iQOUmEcEu~v;-cHwdMtY)jdV-8^6<2H0ga*?$zw8Oa;q+I2 z!sx)!uN_Bi@W(Hh+2-`Lx@#AV(bEZ~!sch#Y;^mX2htgKvbdcur=Ll?(l({tyE*G| zM)h!=o~-Rn`V*2?rcY1D7oQHVt`Ez@x_y6P+dtPoEcd^)(fI1spBHb`(^K|xw0PA^ za?@k@^4o*I7QBMLz5Vch`Nyu0FWm<|1WZr!c_;tR-IFKdwsCf>k95J*^qlANGcxo= zPvixhz1w#SsC~ZW3GtaM$>Er#a4Jo%U1z2F?Pjw*?E9bfI6rOa_43_%0)AM(y;=U* zXTCh0{>MPFIQ}=qv+d@v-msz=ID)VBzY&L?%VmSkcoH=3K#KbQ<}IQ2+QC(SjQH91 zBewQI{CkiyPrA3AxzY~z^NOdPKU*G_x7&A@wx4$EUpkt*?OKVAb(Xkx#U6b6X#xHE z%>;2pq)hHXnVeignYMc$;zYWH0?(`a5C`Rbh<nw2$a6N|^A)(>9HGN*d2_qob)xsX z=aBo=etWn6y8r6t?bjp4i>vJxennr)*W&u-ziV*;x%<O%cWCVJY|GuFM=ySS`9G$N B-@X6< literal 2646 zcmZveTW=gi5QX38SM*Dy1S##RzPA)bBF;q!5XRvx;$c}&93y)dyc-nw^?cQ{UdKsB zmfb$nm#TA4RnMcdm#?3yuhXPX-1d(@C+D6$b0@Dp?ccoJulDoupN>E4l@s5R@VMvC zm!E0gT-dTHJXrtJyt&+ea1rpFiPLU>JFjl0lb7>9SL^xw%?o$>_VfSNyXo)g<dwS1 z-K5@5;0(DNJUx!S{jT=VvRmIw$9Z25cI(Yld}^WA=rsFOYH7{g0Jf&R1p`~@Evu_O z#acshTD&wVxd8QEd&vXhyjtCQYCh){q%L6X#N54V=t-8{QcM9TOYgCdQ!^)Zma2Gd zp|#>P`C5}^mWZ!~V~8*Lq^Tq#P#;T(Q4I@GdTF*=ea|HlKd4wL%nZKN5G#p*Nz8$` zB3M-AZSoqWF^|>k6pC2fj6{PjQCN^;Bt?5<P4*$xo=7@+=;%4v>g+4qLpUgZ#NKD< ziNF>`D>2uRZSSoVM!^SIY~ySgB?}eTeB!l?V>qQmalC^PsI|CSQrKaTG{~SV@;b*L zF8G*R6?jswS&PDhPtpVAbXe*Mj>V@WF<QtbN|D~c(6z<rt_K$3cwoimU884j7z8W( z2wM<nSgf&#trgKoI@Om@LaPgs)DYo-kPqufem!heGcnqk87A1vtd=_YjW`178CHwr zYH?V+1b#gws;mu$m#hjI_nHbOJ*1AvzG`kLw=e{uYwVwcP@e}PV{ukF$j-t+cWTCq z<pBqaEU-LQij>d?*hmiBiVwYO0y3FnZ&r{stLX3*R_l!LgV&Dp55|!Wlf+iFqWAEZ z<US+|DTZNKsO`kIp4d35q~UELa9*38Idy?D=1M>%5ij1dR8yxG))Ur@DKGo+7SB1# zR}pf!$NYpa-zfO?^Z`v#h68O!m8!kc2kt@Ns7LDc+N!h!ZC&BLEuzC3<)QFRZD|fT z2!e#V>wyr2otFZSn5meD2!h%uBd!dmG?C^DvM`STsDjaID3VeeJ{biH$|0svgnC5< zX3_|(r~~b(i<v(8n2NOoupDx+F1VC80uUH2fe<SN3a?p)nqZ|=Yb}vd4UjnICG2R# zvZhkW${NnGXxSY>;RMT%b6ROcCcbNvHY`)=YAC>J+FELAZWyEpQFM%5b3wmI3bfit z{$B}Tx>O$yx-1eTWVy%CDkRhxMZ<nkh=MJV2PUm~mntN~!3dPIsnI*Vz@o+)I-HJ& zq7>^{P=>GRjnN100?Kz}+;BZ|Q|dJg0ZIy$StRE>-V6(`rO=lbr#I_FMs=zbiTVQN zM@^7yEX`QJD#ygb7aYd5fVD=;MB{8~8)R1tvoHg;A4?OB6?V-ul&A$q0b55|oJFUm zJrsh}g8``(E3VE3(Jj5<Q{y96TxHXTnInzsOTtZt_uoyM>DS4w_QSn>cgfp*zI-Go z#yH;n=?L4m!}pubcE5A)e&<_)=j6A4|C-&&+3IpW06)(cSF0D>Pww4lMi1|LyK=`f zF4bjpx6}E&+kU)V&pT&dE9ZypPPd!=yx~Oq1i1sxR<~e|mOLOnLGE%e=>9|J$EyoM zb(X=oyF2G}`;oQ!Rs1`U&vv3`f3}k)xH}$rpZMu&zq;ODK3w|gc78vJtJ9LJ{dOhi zm>uxpp~Z}m+Z>@voc0_cZbtHX2$|2nfC85O0*bVuJ7|Bqy1JfkCsXf-m(cNex4oI4 rxW`u)PZm;6&bQmWvxEo1CvUI*n~8pW*{yE(BOQt}O^+VE`sMZi<vbvv diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index a5149fcee7..555dece7ae 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -1008,7 +1008,6 @@ struct UserText { // "data-broker-protection.optionsMenu" - Menu item data broker protection feature static let dataBrokerProtectionOptionsMenuItem = "Personal Information Removal" - static let dataBrokerProtectionScanOptionsMenuItem = "Personal Information Removal Scan" // "tab.dbp.title" - Tab data broker protection title static let tabDataBrokerProtectionTitle = "Personal Information Removal" diff --git a/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift b/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift index 6ff8bfea32..b2dd2d0a7f 100644 --- a/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift +++ b/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift @@ -344,7 +344,6 @@ final class MoreOptionsMenu: NSMenu { networkProtectionItem = makeNetworkProtectionItem() items.append(networkProtectionItem) - #if SUBSCRIPTION if DefaultSubscriptionFeatureAvailability().isFeatureAvailable() && AccountManager().isUserAuthenticated { Task { @@ -432,19 +431,13 @@ final class MoreOptionsMenu: NSMenu { #if SUBSCRIPTION private func makeInactiveSubscriptionItems() -> [NSMenuItem] { - let dataBrokerProtectionItem = NSMenuItem(title: UserText.dataBrokerProtectionScanOptionsMenuItem, - action: #selector(openSubscriptionPurchasePage(_:)), - keyEquivalent: "") - .targetting(self) - .withImage(.dbpIcon) - let privacyProItem = NSMenuItem(title: UserText.subscriptionOptionsMenuItem, action: #selector(openSubscriptionPurchasePage(_:)), keyEquivalent: "") .targetting(self) .withImage(.subscriptionIcon) - return [dataBrokerProtectionItem, privacyProItem] + return [privacyProItem] } #endif diff --git a/DuckDuckGo/Tab/UserScripts/SubscriptionPagesUserScript.swift b/DuckDuckGo/Tab/UserScripts/SubscriptionPagesUserScript.swift index b0c6391e44..a69c43fa24 100644 --- a/DuckDuckGo/Tab/UserScripts/SubscriptionPagesUserScript.swift +++ b/DuckDuckGo/Tab/UserScripts/SubscriptionPagesUserScript.swift @@ -305,8 +305,7 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature { case .accountCreationFailed: report(subscriptionActivationError: .accountCreationFailed) } - - return nil + await pushPurchaseUpdate(originalMessage: message, purchaseUpdate: PurchaseUpdate(type: "canceled")) } } @@ -524,15 +523,10 @@ extension SubscriptionPagesUseSubscriptionFeature { extension MainWindowController { @MainActor - func showSomethingWentWrongAlert(environment: SubscriptionPurchaseEnvironment.Environment = SubscriptionPurchaseEnvironment.current) { + func showSomethingWentWrongAlert() { guard let window else { return } - switch environment { - case .appStore: - window.show(.somethingWentWrongAlert()) - case .stripe: - window.show(.somethingWentWrongStripeAlert()) - } + window.show(.somethingWentWrongAlert()) } @MainActor diff --git a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/NSAlert+Subscription.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/NSAlert+Subscription.swift index d79042d247..7080cdd43f 100644 --- a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/NSAlert+Subscription.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/NSAlert+Subscription.swift @@ -29,14 +29,6 @@ public extension NSAlert { return alert } - static func somethingWentWrongStripeAlert() -> NSAlert { - let alert = NSAlert() - alert.messageText = UserText.somethingWentWrongAlertTitle - alert.informativeText = UserText.somethingWentWrongStripeAlertDescription - alert.addButton(withTitle: UserText.okButtonTitle) - return alert - } - static func subscriptionNotFoundAlert() -> NSAlert { let alert = NSAlert() alert.messageText = UserText.subscriptionNotFoundAlertTitle diff --git a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionModel.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionModel.swift index 8a0392ec37..c2cd52eba6 100644 --- a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionModel.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionModel.swift @@ -18,11 +18,14 @@ import AppKit import Subscription +import struct Combine.AnyPublisher +import enum Combine.Publishers public final class PreferencesSubscriptionModel: ObservableObject { @Published var isUserAuthenticated: Bool = false @Published var subscriptionDetails: String? + @Published var subscriptionStatus: Subscription.Status? @Published var hasAccessToVPN: Bool = false @Published var hasAccessToDBP: Bool = false @@ -58,6 +61,28 @@ public final class PreferencesSubscriptionModel: ObservableObject { removeSubscriptionClick } + lazy var statePublisher: AnyPublisher<PreferencesSubscriptionState, Never> = { + let isSubscriptionActivePublisher = $subscriptionStatus.map { + $0 != .expired && $0 != .inactive + }.eraseToAnyPublisher() + + let hasAnyEntitlementPublisher = Publishers.CombineLatest3($hasAccessToVPN, $hasAccessToDBP, $hasAccessToITR).map { + return $0 || $1 || $2 + }.eraseToAnyPublisher() + + return Publishers.CombineLatest3($isUserAuthenticated, isSubscriptionActivePublisher, hasAnyEntitlementPublisher) + .map { isUserAuthenticated, isSubscriptionActive, hasAnyEntitlement in + switch (isUserAuthenticated, isSubscriptionActive, hasAnyEntitlement) { + case (false, _, _): return PreferencesSubscriptionState.noSubscription + case (true, false, _): return PreferencesSubscriptionState.subscriptionExpired + case (true, true, false): return PreferencesSubscriptionState.subscriptionPendingActivation + case (true, true, true): return PreferencesSubscriptionState.subscriptionActive + } + } + .removeDuplicates() + .eraseToAnyPublisher() + }() + public init(openURLHandler: @escaping (URL) -> Void, userEventHandler: @escaping (UserEvent) -> Void, sheetActionHandler: SubscriptionAccessActionHandlers, @@ -72,9 +97,32 @@ public final class PreferencesSubscriptionModel: ObservableObject { if let token = accountManager.accessToken { Task { - let subscriptionResult = await SubscriptionService.getSubscription(accessToken: token) + let subscriptionResult = await SubscriptionService.getSubscription(accessToken: token, cachePolicy: .returnCacheDataElseLoad) if case .success(let subscription) = subscriptionResult { - self.updateDescription(for: subscription.expiresOrRenewsAt) + self.updateDescription(for: subscription.expiresOrRenewsAt, status: subscription.status, period: subscription.billingPeriod) + self.subscriptionPlatform = subscription.platform + self.subscriptionStatus = subscription.status + } + + switch await self.accountManager.hasEntitlement(for: .networkProtection, cachePolicy: .returnCacheDataElseLoad) { + case let .success(result): + self.hasAccessToVPN = result + case .failure: + self.hasAccessToVPN = false + } + + switch await self.accountManager.hasEntitlement(for: .dataBrokerProtection, cachePolicy: .returnCacheDataElseLoad) { + case let .success(result): + self.hasAccessToDBP = result + case .failure: + self.hasAccessToDBP = false + } + + switch await self.accountManager.hasEntitlement(for: .identityTheftRestoration, cachePolicy: .returnCacheDataElseLoad) { + case let .success(result): + self.hasAccessToITR = result + case .failure: + self.hasAccessToITR = false } } } @@ -123,6 +171,7 @@ public final class PreferencesSubscriptionModel: ObservableObject { @MainActor func changePlanOrBillingAction() async -> ChangePlanOrBillingAction { + switch subscriptionPlatform { case .apple: if await confirmIfSignedInToSameAccount() { @@ -199,7 +248,20 @@ public final class PreferencesSubscriptionModel: ObservableObject { openURLHandler(.subscriptionFAQ) } - // swiftlint:disable cyclomatic_complexity + @MainActor + func refreshSubscriptionPendingState() { + if SubscriptionPurchaseEnvironment.current == .appStore { + if #available(macOS 12.0, *) { + Task { + _ = await AppStoreRestoreFlow.restoreAccountFromPastPurchase(subscriptionAppGroup: subscriptionAppGroup) + fetchAndUpdateSubscriptionDetails() + } + } + } else { + fetchAndUpdateSubscriptionDetails() + } + } + @MainActor func fetchAndUpdateSubscriptionDetails() { guard fetchSubscriptionDetailsTask == nil else { return } @@ -211,22 +273,12 @@ public final class PreferencesSubscriptionModel: ObservableObject { guard let token = self?.accountManager.accessToken else { return } - let subscriptionResult = await SubscriptionService.getSubscription(accessToken: token) + let subscriptionResult = await SubscriptionService.getSubscription(accessToken: token, cachePolicy: .reloadIgnoringLocalCacheData) if case .success(let subscription) = subscriptionResult { - self?.updateDescription(for: subscription.expiresOrRenewsAt) + self?.updateDescription(for: subscription.expiresOrRenewsAt, status: subscription.status, period: subscription.billingPeriod) self?.subscriptionPlatform = subscription.platform - - if subscription.expiresOrRenewsAt.timeIntervalSinceNow < 0 || !subscription.isActive { - self?.hasAccessToVPN = false - self?.hasAccessToDBP = false - self?.hasAccessToITR = false - - if !subscription.isActive { - self?.accountManager.signOut() - return - } - } + self?.subscriptionStatus = subscription.status } else { self?.accountManager.signOut() } @@ -255,16 +307,33 @@ public final class PreferencesSubscriptionModel: ObservableObject { } } } - // swiftlint:enable cyclomatic_complexity - private func updateDescription(for date: Date) { - self.subscriptionDetails = UserText.preferencesSubscriptionActiveCaption(formattedDate: dateFormatter.string(from: date)) + func updateDescription(for date: Date, status: Subscription.Status, period: Subscription.BillingPeriod) { + + let formattedDate = dateFormatter.string(from: date) + + let billingPeriod: String + + switch period { + case .monthly: billingPeriod = UserText.monthlySubscriptionBillingPeriod.lowercased() + case .yearly: billingPeriod = UserText.yearlySubscriptionBillingPeriod.lowercased() + case .unknown: billingPeriod = "" + } + + switch status { + case .autoRenewable: + self.subscriptionDetails = UserText.preferencesSubscriptionActiveRenewCaption(period: billingPeriod, formattedDate: formattedDate) + case .expired, .inactive: + self.subscriptionDetails = UserText.preferencesSubscriptionExpiredCaption(formattedDate: formattedDate) + default: + self.subscriptionDetails = UserText.preferencesSubscriptionActiveExpireCaption(period: billingPeriod, formattedDate: formattedDate) + } } private var dateFormatter = { let dateFormatter = DateFormatter() - dateFormatter.dateStyle = .medium - dateFormatter.timeStyle = .short + dateFormatter.dateStyle = .long + dateFormatter.timeStyle = .none return dateFormatter }() @@ -277,3 +346,7 @@ enum ManageSubscriptionSheet: Identifiable { return self } } + +enum PreferencesSubscriptionState: String { + case noSubscription, subscriptionPendingActivation, subscriptionActive, subscriptionExpired +} diff --git a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionView.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionView.swift index f17fcb0ea8..5424f3920b 100644 --- a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionView.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionView.swift @@ -21,6 +21,9 @@ import SwiftUI import SwiftUIExtensions public struct PreferencesSubscriptionView: View { + + @State private var state: PreferencesSubscriptionState = .noSubscription + @ObservedObject var model: PreferencesSubscriptionModel @State private var showingSheet = false @State private var showingRemoveConfirmationDialog = false @@ -39,41 +42,14 @@ public struct PreferencesSubscriptionView: View { SubscriptionAccessView(model: model.sheetModel) } .sheet(isPresented: $showingRemoveConfirmationDialog) { - SubscriptionDialog(imageName: "Privacy-Pro-128", - title: UserText.removeSubscriptionDialogTitle, - description: UserText.removeSubscriptionDialogDescription, - buttons: { - Button(UserText.removeSubscriptionDialogCancel) { showingRemoveConfirmationDialog = false } - Button(action: { - showingRemoveConfirmationDialog = false - model.removeFromThisDeviceAction() - }, label: { - Text(UserText.removeSubscriptionDialogConfirm) - .foregroundColor(.red) - }) - }) - .frame(width: 320) + removeConfirmationDialog } .sheet(item: $manageSubscriptionSheet) { sheet in switch sheet { case .apple: - SubscriptionDialog(imageName: "app-store", - title: UserText.changeSubscriptionDialogTitle, - description: UserText.changeSubscriptionAppleDialogDescription, - buttons: { - Button(UserText.changeSubscriptionDialogDone) { manageSubscriptionSheet = nil } - .buttonStyle(DefaultActionButtonStyle(enabled: true)) - }) - .frame(width: 360) + manageSubscriptionAppStoreDialog case .google: - SubscriptionDialog(imageName: "google-play", - title: UserText.changeSubscriptionDialogTitle, - description: UserText.changeSubscriptionGoogleDialogDescription, - buttons: { - Button(UserText.changeSubscriptionDialogDone) { manageSubscriptionSheet = nil } - .buttonStyle(DefaultActionButtonStyle(enabled: true)) - }) - .frame(width: 360) + manageSubscriptionGooglePlayDialog } } @@ -81,93 +57,26 @@ public struct PreferencesSubscriptionView: View { .frame(height: 20) VStack { - if model.isUserAuthenticated { - UniversalHeaderView { - Image(.subscriptionActiveIcon) - .padding(4) - } content: { - TextMenuItemHeader(UserText.preferencesSubscriptionActiveHeader) - TextMenuItemCaption(model.subscriptionDetails ?? "") - } buttons: { - Button(UserText.addToAnotherDeviceButton) { - model.userEventHandler(.addToAnotherDeviceClick) - showingSheet.toggle() - } - - Menu { - Button(UserText.changePlanOrBillingButton, action: { - model.userEventHandler(.changePlanOrBillingClick) - Task { - switch await model.changePlanOrBillingAction() { - case .presentSheet(let sheet): - manageSubscriptionSheet = sheet - case .navigateToManageSubscription(let navigationAction): - navigationAction() - } - } - }) - Button(UserText.removeFromThisDeviceButton, action: { - model.userEventHandler(.removeSubscriptionClick) - showingRemoveConfirmationDialog.toggle() - }) - } label: { - Text(UserText.manageSubscriptionButton) - } - .fixedSize() - } - .onAppear { - model.fetchAndUpdateSubscriptionDetails() - } - - } else { - UniversalHeaderView { - Image(.privacyPro) - .padding(4) - .background(Color("BadgeBackground", bundle: .module)) - .cornerRadius(4) - } content: { - TextMenuItemHeader(UserText.preferencesSubscriptionInactiveHeader) - TextMenuItemCaption(UserText.preferencesSubscriptionInactiveCaption) - } buttons: { - Button(UserText.purchaseButton) { model.purchaseAction() } - .buttonStyle(DefaultActionButtonStyle(enabled: true)) - Button(UserText.haveSubscriptionButton) { - showingSheet.toggle() - model.userEventHandler(.iHaveASubscriptionClick) - } - } + switch state { + case .noSubscription: + unauthenticatedHeaderView + case .subscriptionPendingActivation: + pendingActivationHeaderView + case .subscriptionActive: + authenticatedHeaderView + case .subscriptionExpired: + expiredHeaderView } Divider() .foregroundColor(Color.secondary) .padding(.horizontal, -10) - SectionView(iconName: "VPN-Icon", - title: UserText.vpnServiceTitle, - description: UserText.vpnServiceDescription, - buttonName: model.isUserAuthenticated ? UserText.vpnServiceButtonTitle : nil, - buttonAction: { model.openVPN() }, - enabled: !model.isUserAuthenticated || model.hasAccessToVPN) - - Divider() - .foregroundColor(Color.secondary) - - SectionView(iconName: "PIR-Icon", - title: UserText.personalInformationRemovalServiceTitle, - description: UserText.personalInformationRemovalServiceDescription, - buttonName: model.isUserAuthenticated ? UserText.personalInformationRemovalServiceButtonTitle : nil, - buttonAction: { model.openPersonalInformationRemoval() }, - enabled: !model.isUserAuthenticated || model.hasAccessToDBP) - - Divider() - .foregroundColor(Color.secondary) - - SectionView(iconName: "ITR-Icon", - title: UserText.identityTheftRestorationServiceTitle, - description: UserText.identityTheftRestorationServiceDescription, - buttonName: model.isUserAuthenticated ? UserText.identityTheftRestorationServiceButtonTitle : nil, - buttonAction: { model.openIdentityTheftRestoration() }, - enabled: !model.isUserAuthenticated || model.hasAccessToITR) + if state == .subscriptionActive { + servicesRowsForActiveSubscriptionView + } else { + servicesRowsForNoSubscriptionView + } } .padding(10) .roundedBorder() @@ -175,19 +84,226 @@ public struct PreferencesSubscriptionView: View { Spacer() .frame(height: 24) - PreferencePaneSection { - TextMenuItemHeader(UserText.preferencesSubscriptionFooterTitle) - HStack(alignment: .top, spacing: 6) { - TextMenuItemCaption(UserText.preferencesSubscriptionFooterCaption) - Button(UserText.viewFaqsButton) { model.openFAQ() } - } - } + footerView } .onAppear(perform: { if model.isUserAuthenticated { model.userEventHandler(.activeSubscriptionSettingsClick) } }) + .onReceive(model.statePublisher, perform: updateState(state:)) + } + + private func updateState(state: PreferencesSubscriptionState) { + self.state = state + } + + @ViewBuilder + private var authenticatedHeaderView: some View { + UniversalHeaderView { + Image(.subscriptionActiveIcon) + .padding(4) + } content: { + TextMenuItemHeader(UserText.preferencesSubscriptionActiveHeader) + TextMenuItemCaption(model.subscriptionDetails ?? "") + } buttons: { + Button(UserText.addToAnotherDeviceButton) { + model.userEventHandler(.addToAnotherDeviceClick) + showingSheet.toggle() + } + + Menu { + Button(UserText.changePlanOrBillingButton, action: { + model.userEventHandler(.changePlanOrBillingClick) + Task { + switch await model.changePlanOrBillingAction() { + case .presentSheet(let sheet): + manageSubscriptionSheet = sheet + case .navigateToManageSubscription(let navigationAction): + navigationAction() + } + } + }) + Button(UserText.removeFromThisDeviceButton, action: { + model.userEventHandler(.removeSubscriptionClick) + showingRemoveConfirmationDialog.toggle() + }) + } label: { + Text(UserText.manageSubscriptionButton) + } + .fixedSize() + } + .onAppear { + model.fetchAndUpdateSubscriptionDetails() + } + } + + @ViewBuilder + private var unauthenticatedHeaderView: some View { + UniversalHeaderView { + Image(.privacyPro) + .padding(4) + .background(Color("BadgeBackground", bundle: .module)) + .cornerRadius(4) + } content: { + TextMenuItemHeader(UserText.preferencesSubscriptionInactiveHeader) + TextMenuItemCaption(UserText.preferencesSubscriptionInactiveCaption) + } buttons: { + Button(UserText.purchaseButton) { model.purchaseAction() } + .buttonStyle(DefaultActionButtonStyle(enabled: true)) + Button(UserText.haveSubscriptionButton) { + showingSheet.toggle() + model.userEventHandler(.iHaveASubscriptionClick) + } + .buttonStyle(DismissActionButtonStyle()) + } + } + + @ViewBuilder + private var pendingActivationHeaderView: some View { + UniversalHeaderView { + Image(.subscriptionPendingIcon) + .padding(4) + } content: { + TextMenuItemHeader(UserText.preferencesSubscriptionPendingHeader) + TextMenuItemCaption(UserText.preferencesSubscriptionPendingCaption) + } buttons: { + Button(UserText.restorePurchaseButton) { model.refreshSubscriptionPendingState() } + .buttonStyle(DefaultActionButtonStyle(enabled: true)) + } + .onAppear { + model.fetchAndUpdateSubscriptionDetails() + } + } + + @ViewBuilder + private var expiredHeaderView: some View { + UniversalHeaderView { + Image(.subscriptionExpiredIcon) + .padding(4) + } content: { + TextMenuItemHeader(model.subscriptionDetails ?? UserText.preferencesSubscriptionInactiveHeader) + TextMenuItemCaption(UserText.preferencesSubscriptionExpiredCaption) + } buttons: { + Button(UserText.viewPlansButtonTitle) { model.purchaseAction() } + .buttonStyle(DefaultActionButtonStyle(enabled: true)) + Menu { + Button(UserText.removeFromThisDeviceButton, action: { + model.userEventHandler(.removeSubscriptionClick) + showingRemoveConfirmationDialog.toggle() + }) + } label: { + Text(UserText.manageDevicesButton) + } + .fixedSize() + } + .onAppear { + model.fetchAndUpdateSubscriptionDetails() + } + } + + @ViewBuilder + private var servicesRowsForNoSubscriptionView: some View { + SectionView(iconName: "VPN-Icon", + title: UserText.vpnServiceTitle, + description: UserText.vpnServiceDescription) + + Divider() + .foregroundColor(Color.secondary) + + SectionView(iconName: "PIR-Icon", + title: UserText.personalInformationRemovalServiceTitle, + description: UserText.personalInformationRemovalServiceDescription) + + Divider() + .foregroundColor(Color.secondary) + + SectionView(iconName: "ITR-Icon", + title: UserText.identityTheftRestorationServiceTitle, + description: UserText.identityTheftRestorationServiceDescription) + } + + @ViewBuilder + private var servicesRowsForActiveSubscriptionView: some View { + SectionView(iconName: "VPN-Icon", + title: UserText.vpnServiceTitle, + description: UserText.vpnServiceDescription, + buttonName: UserText.vpnServiceButtonTitle, + buttonAction: { model.openVPN() }, + enabled: model.hasAccessToVPN) + + Divider() + .foregroundColor(Color.secondary) + + SectionView(iconName: "PIR-Icon", + title: UserText.personalInformationRemovalServiceTitle, + description: UserText.personalInformationRemovalServiceDescription, + buttonName: UserText.personalInformationRemovalServiceButtonTitle, + buttonAction: { model.openPersonalInformationRemoval() }, + enabled: model.hasAccessToDBP) + + Divider() + .foregroundColor(Color.secondary) + + SectionView(iconName: "ITR-Icon", + title: UserText.identityTheftRestorationServiceTitle, + description: UserText.identityTheftRestorationServiceDescription, + buttonName: UserText.identityTheftRestorationServiceButtonTitle, + buttonAction: { model.openIdentityTheftRestoration() }, + enabled: model.hasAccessToITR) + } + + @ViewBuilder + private var footerView: some View { + PreferencePaneSection { + TextMenuItemHeader(UserText.preferencesSubscriptionFooterTitle) + HStack(alignment: .top, spacing: 6) { + TextMenuItemCaption(UserText.preferencesSubscriptionFooterCaption) + Button(UserText.viewFaqsButton) { model.openFAQ() } + } + } + } + + @ViewBuilder + private var removeConfirmationDialog: some View { + SubscriptionDialog(imageName: "Privacy-Pro-128", + title: UserText.removeSubscriptionDialogTitle, + description: UserText.removeSubscriptionDialogDescription, + buttons: { + Button(UserText.removeSubscriptionDialogCancel) { showingRemoveConfirmationDialog = false } + Button(action: { + showingRemoveConfirmationDialog = false + model.removeFromThisDeviceAction() + }, label: { + Text(UserText.removeSubscriptionDialogConfirm) + .foregroundColor(.red) + }) + }) + .frame(width: 320) + } + + @ViewBuilder + private var manageSubscriptionAppStoreDialog: some View { + SubscriptionDialog(imageName: "app-store", + title: UserText.changeSubscriptionDialogTitle, + description: UserText.changeSubscriptionAppleDialogDescription, + buttons: { + Button(UserText.changeSubscriptionDialogDone) { manageSubscriptionSheet = nil } + .buttonStyle(DefaultActionButtonStyle(enabled: true)) + }) + .frame(width: 360) + } + + @ViewBuilder + private var manageSubscriptionGooglePlayDialog: some View { + SubscriptionDialog(imageName: "google-play", + title: UserText.changeSubscriptionDialogTitle, + description: UserText.changeSubscriptionGoogleDialogDescription, + buttons: { + Button(UserText.changeSubscriptionDialogDone) { manageSubscriptionSheet = nil } + .buttonStyle(DefaultActionButtonStyle(enabled: true)) + }) + .frame(width: 360) } } diff --git a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/subscription-expired-icon.imageset/Contents.json b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/subscription-expired-icon.imageset/Contents.json new file mode 100644 index 0000000000..0feb43913c --- /dev/null +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/subscription-expired-icon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "subscription-expired-icon.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/subscription-expired-icon.imageset/subscription-expired-icon.pdf b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/subscription-expired-icon.imageset/subscription-expired-icon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d43daeb9b999065914b2ae55c80a3467fee00e9a GIT binary patch literal 2374 zcmbVOOHbS|5Wf3Y%moQ5tz#!~oJa_%ETyX2s&wfM^^j%_Y{e$+CRDV)zGKH88y2aL z9MJ6NnQtB*&v0;YcD|5YGbTa6@Z~E5I6j7x6K49g{mJqa&)%8-X1av}!Ijk-r=4lH zEITtlHl10$K859F`l}n+cP6=TABZ3<NJ`FBQ1U{zWrEjQsJceWqEw=>LO!dFUC*Qn zgqnM!Z2}g&P`a*4kdjwrE{iHgKn4^^$4H+r684XXpu;GAe$fx<KNDhvIu<P=nn)CP z#1g-^?3(rL<KI?T^V}5)6js$rfEA|t?+dQNyqHxLubeel6T%uTq^<#N8)bzmxDec# ziHO)Tm7=WkJYs^&nN)Gllpx-pwewmc#Bi5lxo#{evD0l(41{34L!^XJHrQD+Q-V+o zgi#&t*#DMaQXYS<KjpB{p$hf_q;2OCA}B-uFDkC<274VZ#Xkvz{Ds;41BfNB1ilW` z3v85I^h%JuhQd83SP)1>M98y6DDj$tKDx+8iAApjb^lsq@<f80B4vf2#vYM{&Xuxb zpv1w_fbH>`o-d7)Eh|yA6hcFMdGUovURSDVY>oZPCl1$Q^MoP_686;zaiicVYxEr{ zD8|$!fjCbBNtEc6Od_$ZFx86A?c@zJE$5}Ib9~$dSGCmm)MOP7$sj_Q2w6llk)Amv z8Z;*E=#DHXC^8_#PH!I`VI=Gy5rONl7jBH&mFM?aSblcT%}Y`0qM)5v5{$btd)fEH zG{V(q+@1JkpMHKbkX^Jl#slxodefc`4{#Ot55*P}j#6M+nQ^$^b!G&+omZZ2IrNk1 z(W70>0la8;hyk{?gOUYqAzJL&+szt71)ISNf}iDZkG66z{t=Oc3D;-BWD^|8VN874 zPVL=r^VsxZXRet5&E~GMf1y&UP=K)iqyQ&DPf&qz+K(XYM7)zHP>F#(f^s37JqVv` z(3*Db=FaSxy}dVQSdPPS*qS3a+^mo0qGYRKn7}6N3Y=YTei@A3{m0YpCYMewRn86$ IUVS+K4Zq#t=Kufz literal 0 HcmV?d00001 diff --git a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/subscription-pending-icon.imageset/Contents.json b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/subscription-pending-icon.imageset/Contents.json new file mode 100644 index 0000000000..d161417381 --- /dev/null +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/subscription-pending-icon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Info-Color-16.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/subscription-pending-icon.imageset/Info-Color-16.pdf b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Resources/Subscription.xcassets/Icons/subscription-pending-icon.imageset/Info-Color-16.pdf new file mode 100644 index 0000000000000000000000000000000000000000..42f3cc015dc1fe46421444dd220b6daad5be740b GIT binary patch literal 1745 zcma)7O^?(#5WV|X)MX?<!g1N<F9{*R%!1G^VraM_4oxNujb=Jar&+Q5dY&_h6KD?j z(5il}D%X21SI#ako;_q8m0~i~fB3A39zCMRk5#w7Du0z-<IO+a{`&Zd9KcQS8jgo< zd#jp@?!R5zZJs}+^Ec~%?V!FW)@EE}X5(SCPWS3od#j9g<3gn5WL$=kz0JZjw0o82 zd{Ye5tzxT%lZ|OUjfuU^<07Yh;%My(sySM<_$^6RR+vw#AzYzWEvYM5nJh^vRCBZ- zImuH~RHO2$?iFd(C-w1x+N;-ps&LR=QnCpu1d$j$LE)<ZsZkU*I=aj*P}JThiwh{1 zGGR$GTPK-Vb7qc?l8$Vw@j$YZBxylfWwnde8Cfu^QBMfPs}$szDiU=vft{feH+e;E zvyZ{U%R3v9DQHAT2{<nr_AV_5u2Wmd-tdCe$^%kHLBSQN{EDsy`4h7tuRV*40jyDd zkZ1~)Ob*%7&2KR9w(O%oV;ml&C=ruLvMAd5<i&y!;{u?e!KQ>LQjFv{cr>Oq33Q7R z0Sr^wvTOv-MTy|pT0z;tc@7R<;*;@yYIboYr37o@f|L&P!C2hlUH8DN_8hhtGS6G! zR}z3>sk3^a_RJn^jOKtYC_RK%2oQ*~#z0EZ98Ct%5+{Zu?p1WKW1T=iDtoAB@cv+& zYP{;`GJU4eGS8tha_;66E=KBjf)>lzYM$)({c)go?=i-(nz#S|+fj45z3wL9Wp}mP zKJCBJyR`(<`&*v#V$GR0-OztMwB0~uP;RDl=l%ZJ?Qvq6k~4U@Jpdz69HG@LEl{1Y z&ELB#luCuchURn5`>%-QllliBt4z4BRi;94s0S{@&$q|zO@IA!=(j`nQ5lNrj&=E^ z;Jxz%cv@)v(IQNMX*twcR)v`J31k_4_p?`N^IsqdOW^Ua-Q9Etg^y12;sSEq8~WSs qKHb}0-M<s1+4TL93gJ=U=FRSVhw8_V&-QQ}>9Ds>ot^#l>e&xM_ijW0 literal 0 HcmV?d00001 diff --git a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/Model/ActivateSubscriptionAccessModel.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/Model/ActivateSubscriptionAccessModel.swift index 41063cca08..3e14765641 100644 --- a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/Model/ActivateSubscriptionAccessModel.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/Model/ActivateSubscriptionAccessModel.swift @@ -17,12 +17,13 @@ // import Foundation +import Subscription public final class ActivateSubscriptionAccessModel: SubscriptionAccessModel, PurchaseRestoringSubscriptionAccessModel { private var actionHandlers: SubscriptionAccessActionHandlers public var title = UserText.activateModalTitle - public var description = UserText.activateModalDescription + public var description = UserText.activateModalDescription(platform: SubscriptionPurchaseEnvironment.current) public var email: String? public var emailLabel: String { UserText.email } @@ -31,7 +32,7 @@ public final class ActivateSubscriptionAccessModel: SubscriptionAccessModel, Pur public private(set) var shouldShowRestorePurchase: Bool public var restorePurchaseDescription = UserText.restorePurchaseDescription - public var restorePurchaseButtonTitle = UserText.restorePurchasesButton + public var restorePurchaseButtonTitle = UserText.restorePurchaseButton public init(actionHandlers: SubscriptionAccessActionHandlers, shouldShowRestorePurchase: Bool) { self.actionHandlers = actionHandlers diff --git a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/Model/ShareSubscriptionAccessModel.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/Model/ShareSubscriptionAccessModel.swift index 5ebc6d5433..9e007f4e7e 100644 --- a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/Model/ShareSubscriptionAccessModel.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/Model/ShareSubscriptionAccessModel.swift @@ -21,7 +21,7 @@ import Subscription public final class ShareSubscriptionAccessModel: SubscriptionAccessModel { public var title = UserText.shareModalTitle - public var description = UserText.shareModalDescription + public var description = UserText.shareModalDescription(platform: SubscriptionPurchaseEnvironment.current) private let subscriptionAppGroup: String private var actionHandlers: SubscriptionAccessActionHandlers diff --git a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/SubscriptionAccessView.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/SubscriptionAccessView.swift index 8be8b6f333..bf1931a6fc 100644 --- a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/SubscriptionAccessView.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/SubscriptionAccessView/SubscriptionAccessView.swift @@ -115,6 +115,7 @@ public struct SubscriptionAccessView: View { Button("Cancel") { dismiss() } + .buttonStyle(DismissActionButtonStyle()) } .padding(.horizontal, 20) .padding(.vertical, 16) diff --git a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/UserText.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/UserText.swift index 05c1ff1a5f..f90aba85ad 100644 --- a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/UserText.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/UserText.swift @@ -17,6 +17,7 @@ // import Foundation +import Subscription enum UserText { // MARK: - Subscription preferences @@ -42,11 +43,25 @@ enum UserText { // MARK: Preferences when subscription is active static let preferencesSubscriptionActiveHeader = NSLocalizedString("subscription.preferences.subscription.active.header", value: "Privacy Pro is active on this device", comment: "Header for the subscription preferences pane when the subscription is active") - static func preferencesSubscriptionActiveCaption(formattedDate: String) -> String { - let localized = NSLocalizedString("subscription.preferences.subscription.active.caption", value: "Your monthly Privacy Pro subscription renews on %@", comment: "Caption for the subscription preferences pane when the subscription is active") + + static func preferencesSubscriptionActiveRenewCaption(period: String, formattedDate: String) -> String { + let localized = NSLocalizedString("subscription.preferences.subscription.active.renew.caption", value: "Your %@ Privacy Pro subscription renews on %@.", comment: "Caption for the subscription preferences pane when the subscription is active and will renew. First parameter is renewal period (monthly/yearly). Second parameter is date.") + return String(format: localized, period, formattedDate) + } + + static func preferencesSubscriptionActiveExpireCaption(period: String, formattedDate: String) -> String { + let localized = NSLocalizedString("subscription.preferences.subscription.active.expire.caption", value: "Your %@ Privacy Pro subscription expires on %@.", comment: "Caption for the subscription preferences pane when the subscription is active but will expire. First parameter is renewal period (monthly/yearly). Second parameter is date.") + return String(format: localized, period, formattedDate) + } + + static func preferencesSubscriptionExpiredCaption(formattedDate: String) -> String { + let localized = NSLocalizedString("subscription.preferences.subscription.expired.caption", value: "Your Privacy Pro subscription expired on %@.", comment: "Caption for the subscription preferences pane when the subscription has expired. The parameter is date of expiry.") return String(format: localized, formattedDate) } + static let monthlySubscriptionBillingPeriod = NSLocalizedString("subscription.billing.period.monthly", value: "Monthly", comment: "Type of subscription billing period that lasts a month") + static let yearlySubscriptionBillingPeriod = NSLocalizedString("subscription.billing.period.yearly", value: "Yearly", comment: "Type of subscription billing period that lasts a year") + static let addToAnotherDeviceButton = NSLocalizedString("subscription.preferences.add.to.another.device.button", value: "Add to Another Device…", comment: "Button to add subscription to another device") static let manageSubscriptionButton = NSLocalizedString("subscription.preferences.manage.subscription.button", value: "Manage Subscription", comment: "Button to manage subscription") static let changePlanOrBillingButton = NSLocalizedString("subscription.preferences.change.plan.or.billing.button", value: "Change Plan or Billing...", comment: "Button to add subscription to another device") @@ -59,6 +74,15 @@ enum UserText { static let purchaseButton = NSLocalizedString("subscription.preferences.purchase.button", value: "Get Privacy Pro", comment: "Button to open a page where user can learn more and purchase the subscription") static let haveSubscriptionButton = NSLocalizedString("subscription.preferences.i.have.a.subscription.button", value: "I Have a Subscription", comment: "Button enabling user to activate a subscription user bought earlier or on another device") + // MARK: Preferences when subscription activation is pending + static let preferencesSubscriptionPendingHeader = NSLocalizedString("subscription.preferences.subscription.pending.header", value: "Your Subscription is Being Activated", comment: "Header for the subscription preferences pane when the subscription activation is pending") + static let preferencesSubscriptionPendingCaption = NSLocalizedString("subscription.preferences.subscription.pending.caption", value: "This is taking longer than usual, please check back later.", comment: "Caption for the subscription preferences pane when the subscription activation is pending") + + // MARK: Preferences when subscription is expired + static let preferencesSubscriptionExpiredCaption = NSLocalizedString("subscription.preferences.subscription.expired.caption", value: "Subscribe again to continue using Privacy Pro.", comment: "Caption for the subscription preferences pane when the subscription activation is pending") + + static let manageDevicesButton = NSLocalizedString("subscription.preferences.manage.devices.button", value: "Manage Devices", comment: "Button to manage devices") + // MARK: - Change plan or billing dialogs static let changeSubscriptionDialogTitle = NSLocalizedString("subscription.dialog.change.title", value: "Change Plan or Billing", comment: "Change plan or billing dialog title") static let changeSubscriptionGoogleDialogDescription = NSLocalizedString("subscription.dialog.change.google.description", value: "Your subscription was purchased through the Google Play Store. To change your plan or billing settings, please open Google Play Store subscription settings on a device signed in to the same Google Account used to purchase your subscription.", comment: "Change plan or billing dialog subtitle description for subscription purchased via Google") @@ -76,14 +100,28 @@ enum UserText { // MARK: - Activate subscription modal static let activateModalTitle = NSLocalizedString("subscription.activate.modal.title", value: "Activate your subscription on this device", comment: "Activate subscription modal view title") - static let activateModalDescription = NSLocalizedString("subscription.activate.modal.description", value: "Access your subscription on other devices via Apple ID or an email address.", comment: "Activate subscription modal view subtitle description") + static func activateModalDescription(platform: SubscriptionPurchaseEnvironment.Environment) -> String { + switch platform { + case .appStore: + NSLocalizedString("subscription.appstore.activate.modal.description", value: "Access your subscription on other devices via Apple ID or an email address.", comment: "Activate subscription modal view subtitle description") + case .stripe: + NSLocalizedString("subscription.activate.modal.description", value: "Access your subscription on other devices via an email address.", comment: "Activate subscription modal view subtitle description") + } + } - static let activateModalEmailDescription = NSLocalizedString("subscription.activate.modal.email.description", value: "Use your email to access your subscription on this device.", comment: "Activate subscription modal description for email address channel") + static let activateModalEmailDescription = NSLocalizedString("subscription.activate.modal.email.description", value: "Use your email to activate your subscription on this device.", comment: "Activate subscription modal description for email address channel") static let restorePurchaseDescription = NSLocalizedString("subscription.activate.modal.restore.purchase.description", value: "Your subscription is automatically available in DuckDuckGo on any device signed in to your Apple ID.", comment: "Activate subscription modal description via restore purchase from Apple ID") // MARK: - Share subscription modal - static let shareModalTitle = NSLocalizedString("subscription.share.modal.title", value: "Use your subscription on all your devices", comment: "Share subscription modal view title") - static let shareModalDescription = NSLocalizedString("subscription.share.modal.description", value: "Access your Privacy Pro subscription on any of your devices via Sync, Apple ID or by adding an email address.", comment: "Share subscription modal view subtitle description") + static let shareModalTitle = NSLocalizedString("subscription.share.modal.title", value: "Use your subscription on other devices", comment: "Share subscription modal view title") + static func shareModalDescription(platform: SubscriptionPurchaseEnvironment.Environment) -> String { + switch platform { + case .appStore: + NSLocalizedString("subscription.appstore.share.modal.description", value: "Access your subscription via Apple ID or by adding an email address.", comment: "Share subscription modal view subtitle description") + case .stripe: + NSLocalizedString("subscription.share.modal.description", value: "Activate your Privacy Pro subscription via an email address.", comment: "Share subscription modal view subtitle description") + } + } static let shareModalHasEmailDescription = NSLocalizedString("subscription.share.modal.has.email.description", value: "Use this email to activate your subscription on other devices. Open the DuckDuckGo app on another device and find Privacy Pro in browser settings.", comment: "Share subscription modal description for email address channel") static let shareModalNoEmailDescription = NSLocalizedString("subscription.share.modal.no.email.description", value: "Add an email address to access your subscription in DuckDuckGo on other devices. We’ll only use this address to verify your subscription.", comment: "Share subscription modal description for email address channel") @@ -91,7 +129,7 @@ enum UserText { static let restorePurchasesDescription = NSLocalizedString("subscription.share.modal.restore.purchases.description", value: "Your subscription is automatically available in DuckDuckGo on any device signed in to your Apple ID.", comment: "Share subscription modal description for restoring Apple ID purchases") // MARK: - Activate/share modal buttons - static let restorePurchasesButton = NSLocalizedString("subscription.modal.restore.purchases.button", value: "Restore Purchases", comment: "Button for restoring past subscription purchases") + static let restorePurchaseButton = NSLocalizedString("subscription.modal.restore.purchase.button", value: "Restore Purchase", comment: "Button for restoring past subscription purchase") static let manageEmailButton = NSLocalizedString("subscription.modal.manage.email.button", value: "Manage Email", comment: "Button for opening manage email address page") static let enterEmailButton = NSLocalizedString("subscription.modal.enter.email.button", value: "Enter Email", comment: "Button for opening page to enter email address") static let addEmailButton = NSLocalizedString("subscription.modal.add.email.button", value: "Add Email", comment: "Button for opening page to add email address") @@ -103,8 +141,7 @@ enum UserText { static let restoreButtonTitle = NSLocalizedString("subscription.alert.button.restore", value: "Restore", comment: "Alert button for restoring past subscription purchases") static let somethingWentWrongAlertTitle = NSLocalizedString("subscription.alert.something.went.wrong.title", value: "Something Went Wrong", comment: "Alert title when unknown error has occurred") - static let somethingWentWrongAlertDescription = NSLocalizedString("subscription.alert.something.went.wrong.description", value: "The App Store was not able to process your purchase. Please try again later.", comment: "Alert message when unknown error has occurred") - static let somethingWentWrongStripeAlertDescription = NSLocalizedString("subscription.alert.something.went.wrong.stripe.description", value: "We were not able to start your purchase process. Please try again later.", comment: "Alert message when unknown error has occurred") + static let somethingWentWrongAlertDescription = NSLocalizedString("subscription.alert.something.went.wrong.description", value: "We’re having trouble connecting. Please try again later.", comment: "Alert message when unknown error has occurred") static let subscriptionNotFoundAlertTitle = NSLocalizedString("subscription.alert.subscription.not.found.title", value: "Subscription Not Found", comment: "Alert title when subscription was not found") static let subscriptionNotFoundAlertDescription = NSLocalizedString("subscription.alert.subscription.not.found.description", value: "We couldn’t find a subscription associated with this Apple ID.", comment: "Alert message when subscription was not found") From 4167e4d43f9efae33ff11ba39970751e3ba8e761 Mon Sep 17 00:00:00 2001 From: bwaresiak <bartek@duckduckgo.com> Date: Fri, 15 Mar 2024 13:38:32 +0100 Subject: [PATCH 05/16] Stub objects for Bookmarks DB (#2418) Task/Issue URL: https://app.asana.com/0/414235014887631/1206754257727808/f Description: Provide implementation for stub objects. Steps to test this PR: See BSK PR. --- DuckDuckGo.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 6 ++--- DuckDuckGo/Bookmarks/Model/Bookmark.swift | 25 ++++++++++++++----- DuckDuckGo/Sync/SyncDebugMenu.swift | 25 +++++++++++++++++++ .../DataBrokerProtection/Package.swift | 2 +- .../NetworkProtectionMac/Package.swift | 2 +- LocalPackages/SubscriptionUI/Package.swift | 2 +- 7 files changed, 51 insertions(+), 13 deletions(-) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index a809af52f6..76a2d243ff 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -13749,7 +13749,7 @@ repositoryURL = "https://github.com/duckduckgo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 123.0.1; + version = 124.0.0; }; }; AA06B6B52672AF8100F541C5 /* XCRemoteSwiftPackageReference "Sparkle" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7f8548488a..e8258208b3 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/BrowserServicesKit", "state" : { - "revision" : "838cb53a8f7050d87ae6931b45ce126ece994359", - "version" : "123.0.1" + "branch" : "bartek/stub-objects", + "revision" : "968c429c464688c641d100eab584aa95b039e371" } }, { @@ -165,7 +165,7 @@ { "identity" : "trackerradarkit", "kind" : "remoteSourceControl", - "location" : "https://github.com/duckduckgo/TrackerRadarKit", + "location" : "https://github.com/duckduckgo/TrackerRadarKit.git", "state" : { "revision" : "a6b7ba151d9dc6684484f3785293875ec01cc1ff", "version" : "1.2.2" diff --git a/DuckDuckGo/Bookmarks/Model/Bookmark.swift b/DuckDuckGo/Bookmarks/Model/Bookmark.swift index 99ca656b3f..c7c5f61076 100644 --- a/DuckDuckGo/Bookmarks/Model/Bookmark.swift +++ b/DuckDuckGo/Bookmarks/Model/Bookmark.swift @@ -23,25 +23,32 @@ internal class BaseBookmarkEntity: Identifiable { static func singleEntity(with uuid: String) -> NSFetchRequest<BookmarkEntity> { let request = BookmarkEntity.fetchRequest() - request.predicate = NSPredicate(format: "%K == %@ AND %K == NO", #keyPath(BookmarkEntity.uuid), uuid, #keyPath(BookmarkEntity.isPendingDeletion)) + request.predicate = NSPredicate(format: "%K == %@ AND %K == NO AND (%K == NO OR %K == nil)", + #keyPath(BookmarkEntity.uuid), uuid, + #keyPath(BookmarkEntity.isPendingDeletion), + #keyPath(BookmarkEntity.isStub), #keyPath(BookmarkEntity.isStub)) return request } static func favorite(with uuid: String, favoritesFolder: BookmarkEntity) -> NSFetchRequest<BookmarkEntity> { let request = BookmarkEntity.fetchRequest() - request.predicate = NSPredicate(format: "%K == %@ AND %K CONTAINS %@ AND %K == NO AND %K == NO", + request.predicate = NSPredicate(format: "%K == %@ AND %K CONTAINS %@ AND %K == NO AND %K == NO AND (%K == NO OR %K == nil)", #keyPath(BookmarkEntity.uuid), uuid as CVarArg, #keyPath(BookmarkEntity.favoriteFolders), favoritesFolder, #keyPath(BookmarkEntity.isFolder), - #keyPath(BookmarkEntity.isPendingDeletion)) + #keyPath(BookmarkEntity.isPendingDeletion), + #keyPath(BookmarkEntity.isStub), #keyPath(BookmarkEntity.isStub)) return request } static func entities(with identifiers: [String]) -> NSFetchRequest<BookmarkEntity> { let request = BookmarkEntity.fetchRequest() - request.predicate = NSPredicate(format: "%K IN %@ AND %K == NO", #keyPath(BookmarkEntity.uuid), identifiers, #keyPath(BookmarkEntity.isPendingDeletion)) + request.predicate = NSPredicate(format: "%K IN %@ AND %K == NO AND (%K == NO OR %K == nil)", + #keyPath(BookmarkEntity.uuid), identifiers, + #keyPath(BookmarkEntity.isPendingDeletion), + #keyPath(BookmarkEntity.isStub), #keyPath(BookmarkEntity.isStub)) return request } @@ -98,7 +105,10 @@ final class BookmarkFolder: BaseBookmarkEntity { static func bookmarkFoldersFetchRequest() -> NSFetchRequest<BookmarkEntity> { let request = BookmarkEntity.fetchRequest() - request.predicate = NSPredicate(format: "%K == YES AND %K == NO", #keyPath(BookmarkEntity.isFolder), #keyPath(BookmarkEntity.isPendingDeletion)) + request.predicate = NSPredicate(format: "%K == YES AND %K == NO AND (%K == NO OR %K == nil)", + #keyPath(BookmarkEntity.isFolder), + #keyPath(BookmarkEntity.isPendingDeletion), + #keyPath(BookmarkEntity.isStub), #keyPath(BookmarkEntity.isStub)) return request } @@ -135,7 +145,10 @@ final class Bookmark: BaseBookmarkEntity { static func bookmarksFetchRequest() -> NSFetchRequest<BookmarkEntity> { let request = BookmarkEntity.fetchRequest() - request.predicate = NSPredicate(format: "%K == NO AND %K == NO", #keyPath(BookmarkEntity.isFolder), #keyPath(BookmarkEntity.isPendingDeletion)) + request.predicate = NSPredicate(format: "%K == NO AND %K == NO AND (%K == NO OR %K == nil)", + #keyPath(BookmarkEntity.isFolder), + #keyPath(BookmarkEntity.isPendingDeletion), + #keyPath(BookmarkEntity.isStub), #keyPath(BookmarkEntity.isStub)) return request } diff --git a/DuckDuckGo/Sync/SyncDebugMenu.swift b/DuckDuckGo/Sync/SyncDebugMenu.swift index 6d60705595..da1fe3e0be 100644 --- a/DuckDuckGo/Sync/SyncDebugMenu.swift +++ b/DuckDuckGo/Sync/SyncDebugMenu.swift @@ -18,6 +18,7 @@ import Foundation import DDGSync +import Bookmarks @MainActor final class SyncDebugMenu: NSMenu { @@ -32,6 +33,8 @@ final class SyncDebugMenu: NSMenu { .submenu(environmentMenu) NSMenuItem(title: "Reset Favicons Fetcher Onboarding Dialog", action: #selector(resetFaviconsFetcherOnboardingDialog)) .targetting(self) + NSMenuItem(title: "Populate Stub objects", action: #selector(createStubsForDebug)) + .targetting(self) } } @@ -78,6 +81,28 @@ final class SyncDebugMenu: NSMenu { #endif } + @objc func createStubsForDebug() { +#if DEBUG || REVIEW + let db = BookmarkDatabase.shared + + let context = db.db.makeContext(concurrencyType: .privateQueueConcurrencyType) + + context.performAndWait { + let root = BookmarkUtils.fetchRootFolder(context)! + + _ = BookmarkEntity.makeBookmark(title: "Non stub", url: "url", parent: root, context: context) + let stub = BookmarkEntity.makeBookmark(title: "Stub", url: "", parent: root, context: context) + stub.isStub = true + let emptyStub = BookmarkEntity.makeBookmark(title: "", url: "", parent: root, context: context) + emptyStub.isStub = true + emptyStub.title = nil + emptyStub.url = nil + + try? context.save() + } +#endif + } + @objc func resetFaviconsFetcherOnboardingDialog(_ sender: NSMenuItem) { UserDefaults.standard.set(false, forKey: UserDefaultsWrapper<String>.Key.syncDidPresentFaviconsFetcherOnboarding.rawValue) } diff --git a/LocalPackages/DataBrokerProtection/Package.swift b/LocalPackages/DataBrokerProtection/Package.swift index 8e88a31067..dd701eff21 100644 --- a/LocalPackages/DataBrokerProtection/Package.swift +++ b/LocalPackages/DataBrokerProtection/Package.swift @@ -29,7 +29,7 @@ let package = Package( targets: ["DataBrokerProtection"]) ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "123.0.1"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "124.0.0"), .package(path: "../PixelKit"), .package(path: "../SwiftUIExtensions"), .package(path: "../XPCHelper"), diff --git a/LocalPackages/NetworkProtectionMac/Package.swift b/LocalPackages/NetworkProtectionMac/Package.swift index a5aa5f7c63..000dc4e3f9 100644 --- a/LocalPackages/NetworkProtectionMac/Package.swift +++ b/LocalPackages/NetworkProtectionMac/Package.swift @@ -31,7 +31,7 @@ let package = Package( .library(name: "NetworkProtectionUI", targets: ["NetworkProtectionUI"]) ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "123.0.1"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "124.0.0"), .package(path: "../XPCHelper"), .package(path: "../SwiftUIExtensions"), .package(path: "../LoginItems"), diff --git a/LocalPackages/SubscriptionUI/Package.swift b/LocalPackages/SubscriptionUI/Package.swift index fb17968848..98ceb2ad67 100644 --- a/LocalPackages/SubscriptionUI/Package.swift +++ b/LocalPackages/SubscriptionUI/Package.swift @@ -12,7 +12,7 @@ let package = Package( targets: ["SubscriptionUI"]), ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "123.0.1"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "124.0.0"), .package(path: "../SwiftUIExtensions") ], targets: [ From 409350dcc856c7707adffa55c476a69e1e3a655f Mon Sep 17 00:00:00 2001 From: Pete Smith <peadar81@gmail.com> Date: Fri, 15 Mar 2024 12:56:28 +0000 Subject: [PATCH 06/16] Password Import: Ignore Excluded Sites When Importing from Chrome (#2404) Task/Issue URL: https://app.asana.com/0/0/1206830207129428/f CC: Description: This is a Fix/Improvement as part of macOS: Investigate & Action Top Failed Import Issues for Passwords. --- .../Logins/Chromium/ChromiumLoginReader.swift | 6 ++-- .../DataImport/ChromiumLoginReaderTests.swift | 34 ++++++++++++++++++ .../TestChromeData/Legacy Excluded/Login Data | Bin 0 -> 40960 bytes .../TestChromeData/Legacy/Login Data | Bin 40960 -> 40960 bytes .../TestChromeData/v32 Excluded/Login Data | Bin 0 -> 40960 bytes .../v32 Excluded/Login Data for Account | Bin 0 -> 40960 bytes .../TestChromeData/v32/Login Data | Bin 40960 -> 40960 bytes 7 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 UnitTests/DataImport/DataImportResources/TestChromeData/Legacy Excluded/Login Data create mode 100644 UnitTests/DataImport/DataImportResources/TestChromeData/v32 Excluded/Login Data create mode 100644 UnitTests/DataImport/DataImportResources/TestChromeData/v32 Excluded/Login Data for Account diff --git a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift index 2c5b36ba8e..c423ec9025 100644 --- a/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift +++ b/DuckDuckGo/DataImport/Logins/Chromium/ChromiumLoginReader.swift @@ -65,9 +65,9 @@ final class ChromiumLoginReader { private let decryptionKey: String? private let decryptionKeyPrompt: ChromiumKeychainPrompting - private static let sqlSelectWithPasswordTimestamp = "SELECT signon_realm, username_value, password_value, date_password_modified FROM logins;" - private static let sqlSelectWithCreatedTimestamp = "SELECT signon_realm, username_value, password_value, date_created FROM logins;" - private static let sqlSelectWithoutTimestamp = "SELECT signon_realm, username_value, password_value FROM logins;" + private static let sqlSelectWithPasswordTimestamp = "SELECT signon_realm, username_value, password_value, date_password_modified, blacklisted_by_user FROM logins WHERE blacklisted_by_user != 1;" + private static let sqlSelectWithCreatedTimestamp = "SELECT signon_realm, username_value, password_value, date_created, blacklisted_by_user FROM logins WHERE blacklisted_by_user != 1;" + private static let sqlSelectWithoutTimestamp = "SELECT signon_realm, username_value, password_value, blacklisted_by_user FROM logins WHERE blacklisted_by_user != 1;" private let source: DataImport.Source diff --git a/UnitTests/DataImport/ChromiumLoginReaderTests.swift b/UnitTests/DataImport/ChromiumLoginReaderTests.swift index ba59c59bba..4c5d835e7a 100644 --- a/UnitTests/DataImport/ChromiumLoginReaderTests.swift +++ b/UnitTests/DataImport/ChromiumLoginReaderTests.swift @@ -22,7 +22,9 @@ import XCTest private struct ChromiumLoginStore { static let legacy: Self = .init(directory: "Legacy", decryptionKey: "0geUdf5dTuZmIrtd8Omf/Q==") + static let legacyExcluded: Self = .init(directory: "Legacy Excluded", decryptionKey: "0geUdf5dTuZmIrtd8Omf/Q==") static let v32: Self = .init(directory: "v32", decryptionKey: "IcBAbGhvYp70AP+5W5ojcw==") + static let v32Excluded: Self = .init(directory: "v32 Excluded", decryptionKey: "IcBAbGhvYp70AP+5W5ojcw==") let directory: String let decryptionKey: String @@ -64,6 +66,22 @@ class ChromiumLoginReaderTests: XCTestCase { XCTAssertEqual(logins[2].password, "password") } + func testImportFromVersion32_WithOnlyExcludedSites_IgnoresExcludedCredentials() throws { + // Given + let expectedResult: DataImportResult<[ImportedLoginCredential]> = .success([]) + let reader = ChromiumLoginReader( + chromiumDataDirectoryURL: ChromiumLoginStore.v32Excluded.databaseDirectoryURL, + source: .chrome, + decryptionKey: ChromiumLoginStore.v32Excluded.decryptionKey + ) + + // When + let actualResult = reader.readLogins() + + // Then + XCTAssertEqual(expectedResult, actualResult) + } + func testImportFromLegacyVersion() throws { let reader = ChromiumLoginReader( @@ -83,6 +101,22 @@ class ChromiumLoginReaderTests: XCTestCase { XCTAssertEqual(logins[0].password, "password") } + func testImportFromLegacyVersion_WithOnlyExcludedSites_IgnoresExcludedCredentials() { + // Given + let expectedResult: DataImportResult<[ImportedLoginCredential]> = .success([]) + let reader = ChromiumLoginReader( + chromiumDataDirectoryURL: ChromiumLoginStore.legacyExcluded.databaseDirectoryURL, + source: .chrome, + decryptionKey: ChromiumLoginStore.legacyExcluded.decryptionKey + ) + + // When + let actualResult = reader.readLogins() + + // Then + XCTAssertEqual(expectedResult, actualResult) + } + func testWhenImportingChromiumData_AndTheUserCancelsTheKeychainPrompt_ThenAnErrorIsReturned() { let mockPrompt = MockChromiumPrompt(returnValue: .userDeniedKeychainPrompt) let reader = ChromiumLoginReader( diff --git a/UnitTests/DataImport/DataImportResources/TestChromeData/Legacy Excluded/Login Data b/UnitTests/DataImport/DataImportResources/TestChromeData/Legacy Excluded/Login Data new file mode 100644 index 0000000000000000000000000000000000000000..0acb8dc848e3636e8d752fcf7b06458492928ab0 GIT binary patch literal 40960 zcmeI)PjA~~90%~nO%`Vb1BH&NNJS4jK&zT|gBwVUbPJ}CrEN$zda@kH%d9$f=ErGP z4sf8Hc0jNb>Wv$`fSz_5;>Lj!N5pLsmq~jA<@eZenzUWk6>XI1`$UgZKmYtZpXZ6} z+HF2tDY=ouuLWV#iuk)M&lH7y$T?$7#&L?zQ+$!)D-^R7)1ah340-2l?&>F6)@Rww zHp}j23z_?wZN=Yv4XOJKW~sr*+t1*zFr+6TP|4*c-kMZ|>(`x|UeIuTVG6h52Y#}n zUaS-g)goUkUn+ji2MX||ayNlbCsoYQvApKY@~Pa!?5wgDSvAiQH$3|6o5H!#c6@vP zZm!#WwXjes@_l8eeXHr{RC8uFo13^Wsj#@0-BMON3gUgU+pF`*`zd1j?nzJ`yUlOD z%%~HSlgh2Du}IR*WSborf0D+hgV3dkFx#QWuM{d5KQ2^wd8x|FtEH08Ejx09flQsZ zg%e6F)A5|9<40+=zMKuqYdd@CElY?_5Y`XY=;b6e7D`JCIv4F)(~S-m?w|Lx@xH#+ zEt-0pRz>90X$e(}MzMb=HP5owJy%e!S=%(_C=Mwl?CUhBLo#|jjW%1oe4R&b(-E?< z-XZn))MHX_ES`)f?wHu51=&}q?uwRYZJJUt9gNOhd%yNIr|yK-!C4UNZp-|_2?Nvi zTzft3vCbQg->cB_0;_I{$coxxuS6>l!lh43Y!J_6%W^}}?XBDEBwwy9UM^JD_@~7+ zURbRzEtY8oT`rcZI*-TXSt1%3dv7-QrQ%10)l!w8(|I%4aQ%i^^8$OF?CHeZJ<@7< zab>mGzXJP~P<L_lrNm1_=e>#OmFQm=Gc(8j<e(x1AOHafKmY;|fB*y_009U<00RFT z0WJ3`Q?j>N_BL%0AOL}<C@>N{#e@Avhf;P7<IL2UrWv`@`FwuM$!nUHr_RWz{9tHj zrex2QZz<j~DDP{QdriJmFtq(uu18Tzyf;7J>2&5or(SoXIXh_1$G7L_hh|RG^Ga17 ziNaZmX<^^#h`CK_q~`io6oh;29lhKLwI_!ogM374F`HgdZ$ENw#yh*ayI;kL-Hm*N znnu~i{vaQiq#<ozmTR$2oGjDQJG?UC-?T6LA^YyPJ&$_z_sR;*+B-CB3Wcq@Lerrr z<19D+Xt+n0uxy)dvL{bQTAhva=N62+@BjGV^*@a5t>vFC-1~lL6Kwv?zka><AlAXO z%84Hg{mW)>T9a~;@9r<Z%bij6XQrqPrZ#AU009U<00Izzz!3%3A6jl@YRr(QU&f3D zRbJ5=N_tuI_?NFaYUYY;Fm2-D*R6vWS3}EZHT5o?|96<WL$BZB&SC=r2tWV=5P-mm z6c`=JOe)Erq{{1p502l-QT0zc|KDNi9ois300Izz00ba#;sxHw?A;AG^oD}GHaI){ z_x}?=NOTYa5P$##AOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z1Rwx`qYAu0 z1)rlhN^y*${JvkArcr!}qWnkG(-bol<v*ax?**3M2mCTc1PDL?0uX=z1Rwwb2tWV= z5P$##AOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z1Rwwb2tWV=5P$##AOHaf kKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z1Rhi1AJH3Z1ONa4 literal 0 HcmV?d00001 diff --git a/UnitTests/DataImport/DataImportResources/TestChromeData/Legacy/Login Data b/UnitTests/DataImport/DataImportResources/TestChromeData/Legacy/Login Data index d1e638bf0cc38240ff3c0cd2beb7ab0b59c94af3..f59f562ef053406026e5f105cc3a6059ca0ebf87 100644 GIT binary patch delta 410 zcmZoTz|?SnX@WGP;zSu|Rz(KAaIcLi3&c5?Snn~giLu_>%*av1Jh@K#Nj>8>cXv@) zL0(>WHck!>j+RsoUS3`f1_lNYW}NBHD=G_QFfjH5ako2A4oEkHXdrOsMc0y1Qc_TC zrLSLJUanV^nv#-PqL-YXtDlpfo|&hQO_CEXJNcm87Yj3XNvPSp9GoB*zyJpWHr;T| zo5kebF>)}mw=l4;Vs8QZ&WwGsle&~&psb)fBiIagAn6QrG9y-VRN2ASVwZ%upMCQx vb#V?>cA!ai6C1528L$9tyve|RlN}VEo7v@+u$m3h&ce*g$iDfWxl#rIC+TQo delta 119 zcmZoTz|?SnX@WGP%tRSyRv8ApaF2~C3&c4X*~A#w#F%R~GjbF$Pp*@GvUvhyHzO01 z=)^|#&0=!z7&#c(S23`!Vz&pXFk_$Wq%Jjag5Ty<>f#)%EDQ_`b`u+|CK<2*Ro!D? WzsG(IsA?}eqvS+Kk<Ital`;T<R3ME2 diff --git a/UnitTests/DataImport/DataImportResources/TestChromeData/v32 Excluded/Login Data b/UnitTests/DataImport/DataImportResources/TestChromeData/v32 Excluded/Login Data new file mode 100644 index 0000000000000000000000000000000000000000..6a4ffefe50d8c8490d7a8071714c0442d4959781 GIT binary patch literal 40960 zcmeI5Pi))P8Nf+V){~PkiB<=M=N_J$1(pywo?~P{ll6~cvyL}ajum;EyAA{`(GFpX zR7EPOcj=U*Eef>gxrd@X73i=_55+D42CUeCtcPvbZ5X;8mcuR^2INqnhwOWgB1QeR z(lyH}d@m9IeD8bT``+(8hUE8<$159o(=+IfQ?KeCT@sRlC<@O~DhR?P{HEX+q{DC! z4^Ch&Mvey^P6}s!_`M0HOuk?`$Nk+Pb(A9kB!C2v01`j~51)WMAwwk+`L#g)N&W^q zOh^C;AOR$R1dzbjg23aFbRlKwuBVlqYEAddZOhQE8Fklm?4`vcF=-|hoSi)`N+(m* zs$SDvPxl(`{Nf>KqeSiq<PP~ax%;(fFgl3@kN^@u0!RP}1O#TJ3*vyM0O_zaBetFa zVn?NulaVI?_WmD>Ul1lfg$*VofCL^P0rBp}#);X~S$Q>)gmkMU%W^U~B_yU2S3R%h z!aUkPHsoO%=NA@i<GOnWG=H@r%U}zRegj8x+bT$Y%Ovu^dYBc^`jBhM;LRj>1A_OX zf}3*ko6+;OyYiIG-3^@Chc}aL)7Cwweg+N~EN9oWhiV85=imC-Uq1PS!XzVUb;uL2 zA6kf;6@*dsVqlNWak9kbHFcp3f4uPAGhXH=_wV0-g$r97Ht!kyn2l&U6*&oPGo6}n zo#y~Vp{Bd;b*Elof-h^E)z5sk8<xfFuypHeg7wES=vcqQUJHhg{Nf1xL5H$s9b$g; z-rMg##14_55dpNXszW%L)+Vt1Rdq-|+ib#oSC|*-f&qg`hmQAPe-(~=nF|s}umu<L zSz-f%4K%%O=v>feGib0yRl5how;+?kPrUiLd;a6%Gq<;HOvc|mmHyXv-+1NEug6nQ zOZFe{9ewBL?EODR9Dz9GmvD#)2_OL^fCP{L5_n(&FV3b`*`2t=_E*`{;|nrBf@gRB zFuY3G6En*TB=D^M;E%`2OJL{NNOp!l)+N~czew&0@D~#jKmter2_OL^FvbK9iPE&l zgJo{gfB(NNkk82NF>W$84GACtB!C2vz}OI&l@16bBoEZ!@Be%fVr-~jLy!OxKmter z2_S)k2(b7+-v1Axg!xDS2_OL^fCR>i0N(${t3R<dNB{{S0VIF~Mj^oF|MC5Q6jG>z z1dsp{Kmtf$d<fwEe|-89TY?0T01`j~NMKX~GWlGXkYN44UGn?*3^^~|ks7g2WAnm0 z;@^dnkjawt!Kmg?4+$WFM?v87L_#@zTHNw<SpU;~$%3zXXs+>6!?4TUr-|jFnk%Wa zl)IQ$X_s!=)~iMqN@iYrTq4T!wD{&@+@}8@hWu^3!-6lPX{T<&x;|Q?ZqXm)ip!UB zMOs)d(ZXgvpQU=)g9S;WnOWL!jXJANGc2QO*j`X9l5<VB8b(_ltew+aBFyniT+FXu z%u=_pT{XR4W!K!b!5LU{#i|CzBfeI2fLDfGI~LP=d!qfObi^zxo%x{#}F?`y0T zeYD*2RcKV7jL=hWzZT|asb^LVml+GI%<|8&XyA=b{q|H$w`S@48iN-2ouy{E8|;oz zG3u}sY`5IqGi%yUjk=?iEwj8AIG3fnh8-5DS&m+5EfCw*s5!37mzE7Xt)`oGx8;}_ zI!!MZ*PhQ6x9IoPEt=abt*;eeusp98N?FRe@<r;R%?!cQ74^B?X1+wfouyUhnrZK9 z+m=(_1I+M!ng9Xon-F2ushB%v8(Cjln}xNFO*J|`BLl28^db^IfU-2KBh-kFnoQ<& zJfS=@EefVxF<x$6M0&$>__^j^6WXG`pV$$Ja^i&8T;P{d)$pJLJw4WO88JzE&)BE= z^}=fR1w>noh|tYaf2c>)Gjp+oa^b`vr&tZzBHOa1me|rmLNNbNe)lN;(ZV{B01`j~ zNB{{Sf&ZGocOKkA5dSBCdhoJX0tp}iB!C2v0226rA}~L)GjhBr91&j?$S+7NQJI*I z{~`W@REf_?Q{oq~zd{D4hflyu5oLZ>Y|io65yZ2sie}n74s0N{)IOhT#f#PoE9#GE zTkh-8^+H5O)9jnh8jD%Mm!9ed1QWU4P>^Zr$A~gJE8diOP^xXNee{iv099MwZ9E7Q zhT*rK+6HPwgP@V(e)LsiLD4=%gNWWiP0TDEPbjOigW_TB25F1!<Hrg|6Ur$?^douf zLs_oohqn1~s-qL4*{lQjc@PP&I91)WGtFm@5JgeM8=4=SXBN5M(GI*^6h<livO$nH zLMDvAc7?_vHe5AbSGV9hU=4_+x8l|i*sSrm>rj-P9(M;rGntH>P@Yl-ar7Z9^4&4G zTzF*BJ4}>?b7C{)4`Qcoz);j6zUhxv({_zAj7t`0uR!eI)GhZ&2U_Ud&LWI&zhX39 zgXPn0Pcti-)k6v8$L9t)+OM>>*iU-+#-&N3oI5ApTH>A7Pp6;kP)C>c(};K*^bswp zVVu&iF8t;rN<>gMGYHcPXdyRw{HQCZ_x)VvyzK)75j7WpL3T4AlUmdoe2SPUJ6 z>IzH?_=HVwU;9!QndxfPM!y-5Xq-N{62l(osW+2p&LoMlv?Mlv;*bA*yR1Rr-mo+l zie}RrZm(#v17sfsnjUS^p|hr*3o6r`BSd-fNwI0Ry<_9iGhsTW!@(YDve(BR9h!FG zvlsfV!G?a=;gK+Y_asSBWv{2jL_(RH7F~WpwI&$+ISK!SbX-0i3T*VWMpn9G8Y6uF z|Lo!4=CB4NfCP{L5<mh-;D1EmiQ)AG^Z&!OFc%3R0VIF~kN^@GCj!Cy|2VY?+kym; f01`j~NB{{8CxG|=;e0R`2_OL^fCP}hI1=~|nNt8i literal 0 HcmV?d00001 diff --git a/UnitTests/DataImport/DataImportResources/TestChromeData/v32 Excluded/Login Data for Account b/UnitTests/DataImport/DataImportResources/TestChromeData/v32 Excluded/Login Data for Account new file mode 100644 index 0000000000000000000000000000000000000000..3cd1c61467128db9a6e4d022234bd4522b69b7e6 GIT binary patch literal 40960 zcmeI54R93I9l-bFa+`#Nd=R*NK;1w!IdUPnd;keXyd*~wxm=P<_^w^{?lxh~-UqjP ziGg9@_)wHUnW2u=)+s75l;VIeQlKzcTdbghLY>w^g-VN46&$Ic!btme_wI6+TtFN% z!tme8?Z<on_xAt)z4x-a|NrdU))osdadf-K=VB$g6pP1j9D9(aF$^QnF9ZET%P_Q~ z2_2v<9<e{zE`d#$^-!EFOkI;@Qq|31;*bsmfB+Bx0zd!=3_XG5xMXw^l6noJE>dUF z1{NRy1b_e#00KbZjvz3aB&VbatSB)KkITzSyj|d!F3u<N9(SoRi6C>*LPsU3I5{rO z<zl^zD6x`XEHow}8%gR2MjfF(r~Z9M7z|1R0U!VbfB+Bx0wDr9<P?06y8v<+nS%%K z0f^z`xP*v10Qvc!(3as;rnW3p6>Nb35C8%|00_h)5SUM7wX}@PO({vP(<a2nC$uFe zC&$NUVA>4r0!i|Ul|@Bvu3MbYgPut3yqlFgz6ofzNbq#<?wd=n4hMQR<$P||#g&%O zUB=?|m+$M$)94KI?Bg~2SO0#)4eP*)D%URl;&%&Zq&pQue>rX~$FbdhK~S=h{E~T3 zEeh{HwfZ6!;BeW-f{=~zLu#W#@c5nI$xzq4moCys^KWaYU_Nm!WT<Q2jxzcApP(9X zs*&2Pq{9jb00AHX1b_e#xHSSZDfzCb4!_~;iE<Ov?-u8V1qf^|s<$AeF5!k^G3r%s zD{fdyjsL%jQD0Dhqh7r=b3qyi00AHX1b_e#_+bc)BFWMOvB<TplMUFfLAFovg%W z6J!Ohq;qbGmv~MLXAy}wnT0F23nHOD{~yGtGt|K!#sWAi5C8%|00;m9AaF|ra><+c z8VH)aiPu1l|64HXKCR`J&JDsq00;m9AOHk{1Ol@N4Yr~b#p^E^8s13UoK(BlUAE-> z6z=}4!e{sV<9fE{?>n$rzx{flt?mWe192<1Z<_nns*S5h@7-IA9pA;Rocj3NGgB_n zC2LOZ-nsblk+bja`Xsw}o#Tb#j`#K$X06@danYan^4@va`0Ss(^=3)M#MKLS9r$`% zO48D?TgP5GEfMz1nfk5!TD#YOcE!{z?90S@H$Q9tOn>IB*PqKvI`#46&mYjaQYJm7 zIeg_mA8b1Mj^SA5=O?bOeC6XOethMysq*Byx@E$Jd(AtyY-OKrU%ctlpVVLYYx?2E zSv6Nb49voVmc_;8Y<OkC)jyuYdU_A8c>bpwPW}1ZwDzOhMx_@{DPQ-xn6&>-2Y>Wp z@}6<;J$>m{dt5%tyOm!$mwxg_wf+4kMtrz+L#4FoVurKsnQ`kcKXm$^n>O2eKOH}P z{D~=xr|()da${H9FMf0Gz?vmX*PPwc@_u&ClH=cOcfN>!F!9{^b(*QaG>uq0W?y>! zQK{!r^IxvT?XBXH#yqeYH!ZAW=Fhq3Bze8QId^^G+ShikJ#;AW+=arA7Hy;t9oT*9 z=LL=1T8`~_(z@G!Y}woX-AzxPUi04qV%Z}*Ka#Kixb_bi`hx`s00AHX1c1PuLty#+ z=+eA$N#Yhf{^j#T`mgcyUOb*iw<c;632%2QmRg#fo`7Ao>c2b{hvkNL#%3h6&L2@O zr={Xqsa4YxOfR+0FUd?C6;Gr+JSL$+&dEgb!6kjNT%X-JSbc++q_O&MdRJWhUy)bc zq+{8$%WJ&VMn^%7Fl(l{+s5(Jipn2#71W!{%Dvq+U1h$xVpsFR;-=ZvjeK))wP<Re zKC{tVVXAda6vP7F)yYrx@%Gy8PS@O?M@ps{%Ub=#w$|>dDjFZ3=_+$H6wUOuwp$vE zT`k>{dW2~e_Li!ul99$@qtRGVQ8uyMSTeDEa%stAWBDZc`j004fdvQv0U!VbfB+D< ziwMZ^f4KhNMGX$70s$ZZ1b_e#Km_3W58VI=00AHX1c1QZM<AK{3X3Dr{C^$PaZL_Y zNgg5n#G6DR_6+_nY#hpz7jnzp2L>z#0(TOD<~Xgcpa7pEv1tBJaiM^|>cNQILO<tr z^gq<r*vzIjGu>vYwwURDvib6?D+W|j|JZ1f(q(1gy(5%5)fa};wI-?tHI2^q_;@tG z3*+|*^h}ejrru<utxavTb%w=apjn56CM1o_G|-_*UKmc`T<ATeP_c-dE>`e!ed%6S z6uUh>=fD!-9JNHXrK#FLi+;O{mj()pe1{t;<KtMt6{<W!SM&lE$2d@~#5w5(Yn!>w z9BGPOU>%(TFQQz=-owaN#E=pl3y@JUGQyflJ>GD>ftGj|dPxga7Cxmx`78?~zs^Og z_BeUbE3iF`+(se24K(lQ54N3iay~Q_Z2xqzllL-<IG=}c2)v^+q@027;N0N?UctjU zgA>H|Nq9Y?s7x&zZZt2;`^2DPd{}9^+1Ai#vdy8Vo9ED`8Es7sR@7M<&DJ&pt#DN) zsf#o-44$qvPczN1w9&-|+U4ou-5rcw@HjdVX7qiU5CXYv!U)kzZ+tu7hb(4YGpr3Q zGt80wGoph9yIw?!(t!+g_#9!0$X=t@7ihG)saZJ2yPe#l!9m3OC698<sAGaLs@u4B zoYIXPi3f_5p(MYgC;y13VI-&IcXB<nrO8^?e*n=zBO>S;Xtk?Hi0ktRt!~Q5!IYBE zz!>E<iT<@IQGba4-%0=EL02FE1b_e#00Q3+0r3BSKOCVJAOHk_01yBIcM^eQjSWk} zS7Fq4iqJaavNZ2#=8{fL9+`n(BhH`<ScaZ}lt$?abMZi);yXhAEWyd}?sgAaA>UHp zaYoQz)L^YO&!+p*RXMuJ8j(il%inZnWS<rK(vy!v&P1i&aDqO-rck=vTzo~c;*{#E zu5T|r%0bna-oNsYn=tHt8*sKkB_f@m5ye&SD<eBaV-yW}^loONzBE;<tIHkCAJ%6O zW9-`>VI8j3-K)b@PoDgtERj)N+sb}gR3k)Y%MG9$hdkj<kBjBq`oM!pluoC^mout! zUanD*8y<yMNeX)@)v_TcZv>gJ|FxfM9QlS_yeP5)`VN>M`O<@aYvkDUDt^}+qKwwK zzcW;?*C%UrRl31Ast}CICIt-_#j_|4qjW{(cpy#f#2z1qx*~)8n`&?6-6H2e{ZjU` zJLNgbSV2sRLW>$Vx`@m-Rxvu?%lc3i4DZy}C2Do^$_G<4R%yl<D|OiN`UFZ>UXHIU zRT?dpOsurTs3wgi5ut6UjcA*Bn%QQy)|gxAU@!%lI-|K(j?&baT5C+TX4&{!v&9^d zg52e1n@tJgG*}zj8cY_;9NCAC63w+JET9tRr5@%x4Kf~08Rw4`0g3d}hel$!1qPf~ zuMgzJQ@YYpJn*pE{|C-i7h?jugeZ_P1nB&47j%f87jM9T(g*S=N>@>V2lzhi2F#fk zdnUd3fjSN-N#~2`^9O7PCwKjDgGadW`v*y6_q-DF=^p~n+q7C;UKTDY11cC`P-7D6 z0TnfTq6FmL6YN>}QDF>t{vZ0wJDdRs00AHX1c1QpMgZdfw_A&VK0p8n00AHX1csgf pJpT_pV>km400KY&2mpcGjR0K#Z?_f!eSiQE00KY&2n;=e{{!tc{`UX? literal 0 HcmV?d00001 diff --git a/UnitTests/DataImport/DataImportResources/TestChromeData/v32/Login Data b/UnitTests/DataImport/DataImportResources/TestChromeData/v32/Login Data index 76ab8de045152236833e62ee84aebff573ed9e65..6ffc00b624f06022593919113f0210425583e72e 100644 GIT binary patch delta 281 zcmZoTz|?SnX@WE(=R_H2R!#=JaIcLi3&gpYnL`*@3z>VELpC;YGS^oyFff$3^U8_> z=^P-=aOdUaHD=)D;N$@Ez#tSP#ET-tz`()4kx^pCDG8PXN+YR<N|qE9Tj}eUmzV1m zrKY50mgptt=j!L=r)TErHz#pO1qO;L3L0&mCY{H^#muhC!0yeym|b;aBNw}|8PHlI zSy5g_Ag!g!4z_|ZqXdtEoSVJXw{Y-tashQPvu|f$-_CxIea~h=j+N}}EX=%&oQlko N1J&g>-!)gx0022tIGX?f delta 90 zcmV-g0Hyzczyg540+1U42$38^1qc8xS4pvCpce=N1B?I#aRZF8fq(<Ej2n0a2m%L7 w00&D4qp^WM2eV5jv<M9d3IG5A0tdVR2i^y~vk?fe2Ld4jlTjxhv)wl%XjuLjTL1t6 From 7d524cce0a6a5b8f7999cee64eef63a9b03ec799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariusz=20=C5=9Apiewak?= <dus7@users.noreply.github.com> Date: Fri, 15 Mar 2024 14:03:57 +0100 Subject: [PATCH 07/16] Roll back CPM post-rollout cleanup (#2430) Task/Issue URL: https://app.asana.com/0/414235014887631/1206850469068241/f Tech Design URL: CC: **Description**: Updates the BSK reference as a part of rolling back https://github.com/duckduckgo/macos-browser/pull/2316 **Steps to test this PR**: 1. CI is green <!-- Tagging instructions If this PR isn't ready to be merged for whatever reason it should be marked with the `DO NOT MERGE` label (particularly if it's a draft) If it's pending Product Review/PFR, please add the `Pending Product Review` label. If at any point it isn't actively being worked on/ready for review/otherwise moving forward (besides the above PR/PFR exception) strongly consider closing it (or not opening it in the first place). If you decide not to close it, make sure it's labelled to make it clear the PRs state and comment with more information. --> --- ###### Internal references: [Pull Request Review Checklist](https://app.asana.com/0/1202500774821704/1203764234894239/f) [Software Engineering Expectations](https://app.asana.com/0/59792373528535/199064865822552) [Technical Design Template](https://app.asana.com/0/59792373528535/184709971311943) [Pull Request Documentation](https://app.asana.com/0/1202500774821704/1204012835277482/f) --- DuckDuckGo.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 6 +++--- LocalPackages/DataBrokerProtection/Package.swift | 2 +- LocalPackages/NetworkProtectionMac/Package.swift | 2 +- LocalPackages/SubscriptionUI/Package.swift | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 76a2d243ff..23d4cdecf1 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -13749,7 +13749,7 @@ repositoryURL = "https://github.com/duckduckgo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 124.0.0; + version = 124.1.0; }; }; AA06B6B52672AF8100F541C5 /* XCRemoteSwiftPackageReference "Sparkle" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e8258208b3..5d9e06d99f 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/BrowserServicesKit", "state" : { - "branch" : "bartek/stub-objects", - "revision" : "968c429c464688c641d100eab584aa95b039e371" + "revision" : "bcafd206465427c560f9f581def57d8eef53748c", + "version" : "124.1.0" } }, { @@ -165,7 +165,7 @@ { "identity" : "trackerradarkit", "kind" : "remoteSourceControl", - "location" : "https://github.com/duckduckgo/TrackerRadarKit.git", + "location" : "https://github.com/duckduckgo/TrackerRadarKit", "state" : { "revision" : "a6b7ba151d9dc6684484f3785293875ec01cc1ff", "version" : "1.2.2" diff --git a/LocalPackages/DataBrokerProtection/Package.swift b/LocalPackages/DataBrokerProtection/Package.swift index dd701eff21..e07c2c873c 100644 --- a/LocalPackages/DataBrokerProtection/Package.swift +++ b/LocalPackages/DataBrokerProtection/Package.swift @@ -29,7 +29,7 @@ let package = Package( targets: ["DataBrokerProtection"]) ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "124.0.0"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "124.1.0"), .package(path: "../PixelKit"), .package(path: "../SwiftUIExtensions"), .package(path: "../XPCHelper"), diff --git a/LocalPackages/NetworkProtectionMac/Package.swift b/LocalPackages/NetworkProtectionMac/Package.swift index 000dc4e3f9..5c07540155 100644 --- a/LocalPackages/NetworkProtectionMac/Package.swift +++ b/LocalPackages/NetworkProtectionMac/Package.swift @@ -31,7 +31,7 @@ let package = Package( .library(name: "NetworkProtectionUI", targets: ["NetworkProtectionUI"]) ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "124.0.0"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "124.1.0"), .package(path: "../XPCHelper"), .package(path: "../SwiftUIExtensions"), .package(path: "../LoginItems"), diff --git a/LocalPackages/SubscriptionUI/Package.swift b/LocalPackages/SubscriptionUI/Package.swift index 98ceb2ad67..c29571c2ce 100644 --- a/LocalPackages/SubscriptionUI/Package.swift +++ b/LocalPackages/SubscriptionUI/Package.swift @@ -12,7 +12,7 @@ let package = Package( targets: ["SubscriptionUI"]), ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "124.0.0"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "124.1.0"), .package(path: "../SwiftUIExtensions") ], targets: [ From c299d42a0f292e156efeb392d89195c41de93e13 Mon Sep 17 00:00:00 2001 From: Juan Manuel Pereira <juanmanuel.pereira1@gmail.com> Date: Fri, 15 Mar 2024 10:36:10 -0300 Subject: [PATCH 08/16] DBP: Debug scan model implementation (#2421) --- .../CCF/DataBrokerProtectionFeature.swift | 4 +- .../CCF/WebViewHandler.swift | 71 +++ .../DebugUI/DataBrokerRunCustomJSONView.swift | 60 ++- .../DataBrokerRunCustomJSONViewModel.swift | 425 ++++++++++++++++-- .../DebugUI/DebugScanOperation.swift | 181 ++++++++ .../Model/DataBrokerProtectionProfile.swift | 2 +- .../Model/ProfileQuery.swift | 15 + .../Operations/OptOutOperation.swift | 2 +- .../Operations/ScanOperation.swift | 2 +- .../DataBrokerProtectionFeatureTests.swift | 2 +- .../DataBrokerProtectionTests/Mocks.swift | 8 + 11 files changed, 700 insertions(+), 72 deletions(-) create mode 100644 LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/DebugUI/DebugScanOperation.swift diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/CCF/DataBrokerProtectionFeature.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/CCF/DataBrokerProtectionFeature.swift index c596488888..be97096c4e 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/CCF/DataBrokerProtectionFeature.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/CCF/DataBrokerProtectionFeature.swift @@ -24,7 +24,7 @@ import Common protocol CCFCommunicationDelegate: AnyObject { func loadURL(url: URL) async - func extractedProfiles(profiles: [ExtractedProfile]) async + func extractedProfiles(profiles: [ExtractedProfile], meta: [String: Any]?) async func captchaInformation(captchaInfo: GetCaptchaInfoResponse) async func solveCaptcha(with response: SolveCaptchaResponse) async func success(actionId: String, actionType: ActionType) async @@ -101,7 +101,7 @@ struct DataBrokerProtectionFeature: Subfeature { await delegate?.onError(error: DataBrokerProtectionError.malformedURL) } case .extract(let profiles): - await delegate?.extractedProfiles(profiles: profiles) + await delegate?.extractedProfiles(profiles: profiles, meta: success.meta) case .getCaptchaInfo(let captchaInfo): await delegate?.captchaInformation(captchaInfo: captchaInfo) case .solveCaptcha(let response): diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/CCF/WebViewHandler.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/CCF/WebViewHandler.swift index c89644eec4..ab98fa98b9 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/CCF/WebViewHandler.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/CCF/WebViewHandler.swift @@ -25,6 +25,8 @@ import Common protocol WebViewHandler: NSObject { func initializeWebView(showWebView: Bool) async func load(url: URL) async throws + func takeSnaphost(path: String, fileName: String) async throws + func saveHTML(path: String, fileName: String) async throws func waitForWebViewLoad(timeoutInSeconds: Int) async throws func finish() async func execute(action: Action, data: CCFRequestData) async @@ -122,6 +124,75 @@ final class DataBrokerProtectionWebViewHandler: NSObject, WebViewHandler { func evaluateJavaScript(_ javaScript: String) async throws { _ = webView?.evaluateJavaScript(javaScript, in: nil, in: WKContentWorld.page) } + + func takeSnaphost(path: String, fileName: String) async throws { + let script = "document.body.scrollHeight" + + let result = try await webView?.evaluateJavaScript(script) + + if let height = result as? CGFloat { + webView?.frame = CGRect(origin: .zero, size: CGSize(width: 1024, height: height)) + let configuration = WKSnapshotConfiguration() + configuration.rect = CGRect(x: 0, y: 0, width: webView?.frame.size.width ?? 0.0, height: height) + if let image = try await webView?.takeSnapshot(configuration: configuration) { + saveToDisk(image: image, path: path, fileName: fileName) + } + } + } + + func saveHTML(path: String, fileName: String) async throws { + let result = try await webView?.evaluateJavaScript("document.documentElement.outerHTML") + let fileManager = FileManager.default + + if let htmlString = result as? String { + do { + if !fileManager.fileExists(atPath: path) { + try fileManager.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil) + } + + let fileURL = URL(fileURLWithPath: "\(path)/\(fileName)") + try htmlString.write(to: fileURL, atomically: true, encoding: .utf8) + print("HTML content saved to file: \(fileURL)") + } catch { + print("Error writing HTML content to file: \(error)") + } + } + } + + private func saveToDisk(image: NSImage, path: String, fileName: String) { + guard let tiffData = image.tiffRepresentation else { + // Handle the case where tiff representation is not available + return + } + + // Create a bitmap representation from the tiff data + guard let bitmapImageRep = NSBitmapImageRep(data: tiffData) else { + // Handle the case where bitmap representation cannot be created + return + } + + let fileManager = FileManager.default + + if !fileManager.fileExists(atPath: path) { + do { + try fileManager.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil) + } catch { + print("Error creating folder: \(error)") + } + } + + if let pngData = bitmapImageRep.representation(using: .png, properties: [:]) { + // Save the PNG data to a file + do { + let fileURL = URL(fileURLWithPath: "\(path)/\(fileName)") + try pngData.write(to: fileURL) + } catch { + print("Error writing PNG: \(error)") + } + } else { + print("Error png data was not respresented") + } + } } extension DataBrokerProtectionWebViewHandler: WKNavigationDelegate { diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/DebugUI/DataBrokerRunCustomJSONView.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/DebugUI/DataBrokerRunCustomJSONView.swift index 33671421d5..22b8c6b90a 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/DebugUI/DataBrokerRunCustomJSONView.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/DebugUI/DataBrokerRunCustomJSONView.swift @@ -28,26 +28,42 @@ struct DataBrokerRunCustomJSONView: View { if viewModel.results.isEmpty { VStack(alignment: .leading) { Text("macOS App version: \(viewModel.appVersion())") - Text("C-S-S version: \(viewModel.contentScopeScriptsVersion())") Divider() - HStack { - TextField("First name", text: $viewModel.firstName) - .padding() - TextField("Last name", text: $viewModel.lastName) - .padding() - TextField("Middle", text: $viewModel.middle) - .padding() + ForEach(viewModel.names.indices, id: \.self) { index in + HStack { + TextField("First name", text: $viewModel.names[index].first) + .padding() + TextField("Middle", text: $viewModel.names[index].middle) + .padding() + TextField("Last name", text: $viewModel.names[index].last) + .padding() + } + } + + Button("Add other name") { + viewModel.names.append(.empty()) } Divider() - HStack { - TextField("City", text: $viewModel.city) - .padding() - TextField("State", text: $viewModel.state) - .padding() + ForEach(viewModel.addresses.indices, id: \.self) { index in + HStack { + TextField("City", text: $viewModel.addresses[index].city) + .padding() + TextField("State (two characters format)", text: $viewModel.addresses[index].state) + .onChange(of: viewModel.addresses[index].state) { newValue in + if newValue.count > 2 { + viewModel.addresses[index].state = String(newValue.prefix(2)) + } + } + .padding() + } + } + + Button("Add other address") { + viewModel.addresses.append(.empty()) } Divider() @@ -76,6 +92,14 @@ struct DataBrokerRunCustomJSONView: View { Button("Run") { viewModel.runJSON(jsonString: jsonText) } + + if viewModel.isRunningOnAllBrokers { + ProgressView("Scanning...") + } else { + Button("Run all brokers") { + viewModel.runAllBrokers() + } + } } .padding() .frame(minWidth: 600, minHeight: 800) @@ -88,19 +112,19 @@ struct DataBrokerRunCustomJSONView: View { } else { VStack { VStack { - List(viewModel.results, id: \.name) { extractedProfile in + List(viewModel.results, id: \.id) { scanResult in HStack { - Text(extractedProfile.name ?? "No name") + Text(scanResult.extractedProfile.name ?? "No name") .padding(.horizontal, 10) Divider() - Text(extractedProfile.addresses?.first?.fullAddress ?? "No address") + Text(scanResult.extractedProfile.addresses?.first?.fullAddress ?? "No address") .padding(.horizontal, 10) Divider() - Text(extractedProfile.relatives?.joined(separator: ",") ?? "No relatives") + Text(scanResult.extractedProfile.relatives?.joined(separator: ",") ?? "No relatives") .padding(.horizontal, 10) Divider() Button("Opt-out") { - viewModel.runOptOut(extractedProfile: extractedProfile) + viewModel.runOptOut(scanResult: scanResult) } } }.navigationTitle("Results") diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/DebugUI/DataBrokerRunCustomJSONViewModel.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/DebugUI/DataBrokerRunCustomJSONViewModel.swift index e0241d5542..0cb75a8f1d 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/DebugUI/DataBrokerRunCustomJSONViewModel.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/DebugUI/DataBrokerRunCustomJSONViewModel.swift @@ -22,6 +22,49 @@ import Common import ContentScopeScripts import Combine +struct ExtractedAddress: Codable { + let state: String + let city: String +} + +struct UserData: Codable { + let firstName: String + let lastName: String + let middleName: String? + let state: String + let email: String? + let city: String + let age: Int + let addresses: [ExtractedAddress] +} + +struct ProfileUrl: Codable { + let profileUrl: String + let identifier: String +} + +struct ScrapedData: Codable { + let name: String? + let alternativeNamesList: [String]? + let age: String? + let addressCityState: String? + let addressCityStateList: [ExtractedAddress]? + let relativesList: [String]? + let profileUrl: ProfileUrl? +} + +struct ExtractResult: Codable { + let scrapedData: ScrapedData + let result: Bool + let score: Int + let matchedFields: [String] +} + +struct Metadata: Codable { + let userData: UserData + let extractResults: [ExtractResult] +} + struct AlertUI { var title: String = "" var description: String = "" @@ -30,28 +73,80 @@ struct AlertUI { AlertUI(title: "No results", description: "No results were found.") } + static func finishedScanningAllBrokers() -> AlertUI { + AlertUI(title: "Finished!", description: "We finished scanning all brokers. You should find the data inside ~/Desktop/PIR-Debug/") + } + static func from(error: DataBrokerProtectionError) -> AlertUI { AlertUI(title: error.title, description: error.description) } } -final class DataBrokerRunCustomJSONViewModel: ObservableObject { +final class NameUI: ObservableObject { + let id = UUID() + @Published var first: String + @Published var middle: String + @Published var last: String + + init(first: String, middle: String = "", last: String) { + self.first = first + self.middle = middle + self.last = last + } - @Published var firstName: String = "" - @Published var lastName: String = "" - @Published var middle: String = "" - @Published var city: String = "" - @Published var state: String = "" + static func empty() -> NameUI { + .init(first: "", middle: "", last: "") + } + + func toModel() -> DataBrokerProtectionProfile.Name { + .init(firstName: first, lastName: last, middleName: middle.isEmpty ? nil : middle) + } +} + +final class AddressUI: ObservableObject { + let id = UUID() + @Published var city: String + @Published var state: String + + init(city: String, state: String) { + self.city = city + self.state = state + } + + static func empty() -> AddressUI { + .init(city: "", state: "") + } + + func toModel() -> DataBrokerProtectionProfile.Address { + .init(city: city, state: state) + } +} + +struct ScanResult { + let id = UUID() + let dataBroker: DataBroker + let profileQuery: ProfileQuery + let extractedProfile: ExtractedProfile +} + +final class DataBrokerRunCustomJSONViewModel: ObservableObject { @Published var birthYear: String = "" - @Published var results = [ExtractedProfile]() + @Published var results = [ScanResult]() @Published var showAlert = false @Published var showNoResults = false + @Published var isRunningOnAllBrokers = false + @Published var names = [NameUI.empty()] + @Published var addresses = [AddressUI.empty()] + var alert: AlertUI? var selectedDataBroker: DataBroker? let brokers: [DataBroker] private let runnerProvider: OperationRunnerProvider + private let privacyConfigManager: PrivacyConfigurationManaging + private let contentScopeProperties: ContentScopeProperties + private let csvColumns = ["name_input", "age_input", "city_input", "state_input", "name_scraped", "age_scraped", "address_scraped", "relatives_scraped", "url", "broker name", "screenshot_id", "error", "matched_fields", "result_match", "expected_match"] init() { let privacyConfigurationManager = PrivacyConfigurationManagingMock() @@ -69,45 +164,202 @@ final class DataBrokerRunCustomJSONViewModel: ObservableObject { let contentScopeProperties = ContentScopeProperties(gpcEnabled: false, sessionKey: sessionKey, featureToggles: features) + self.runnerProvider = DataBrokerOperationRunnerProvider( privacyConfigManager: privacyConfigurationManager, contentScopeProperties: contentScopeProperties, emailService: EmailService(), captchaService: CaptchaService()) + self.privacyConfigManager = privacyConfigurationManager + self.contentScopeProperties = contentScopeProperties let fileResources = FileResources() self.brokers = fileResources.fetchBrokerFromResourceFiles() ?? [DataBroker]() } - func runJSON(jsonString: String) { - if firstName.isEmpty || lastName.isEmpty || city.isEmpty || state.isEmpty || birthYear.isEmpty { + func runAllBrokers() { + isRunningOnAllBrokers = true + + let brokerProfileQueryData = createBrokerProfileQueryData() + + Task.detached { + var scanResults = [DebugScanReturnValue]() + let semaphore = DispatchSemaphore(value: 10) + try await withThrowingTaskGroup(of: DebugScanReturnValue.self) { group in + for queryData in brokerProfileQueryData { + semaphore.wait() + let debugScanOperation = DebugScanOperation(privacyConfig: self.privacyConfigManager, prefs: self.contentScopeProperties, query: queryData) { + true + } + + group.addTask { + defer { + semaphore.signal() + } + do { + return try await debugScanOperation.run(inputValue: (), stageCalculator: FakeStageDurationCalculator(), showWebView: false) + } catch { + return DebugScanReturnValue(brokerURL: "ERROR - with broker: \(queryData.dataBroker.name)", extractedProfiles: [ExtractedProfile](), brokerProfileQueryData: queryData) + } + } + } + + for try await result in group { + scanResults.append(result) + } + + self.formCSV(with: scanResults) + + self.finishLoading() + } + } + } + + private func finishLoading() { + DispatchQueue.main.async { + self.alert = AlertUI.finishedScanningAllBrokers() self.showAlert = true - self.alert = AlertUI(title: "Error", description: "Some required fields were not entered.") - return + self.isRunningOnAllBrokers = false + } + } + + private func formCSV(with scanResults: [DebugScanReturnValue]) { + var csvText = csvColumns.map { $0 }.joined(separator: ",") + csvText.append("\n") + + for result in scanResults { + if let error = result.error { + csvText.append(append(error: error, for: result)) + } else { + csvText.append(append(result)) + } + } + + save(csv: csvText) + } + + private func append(error: Error, for result: DebugScanReturnValue) -> String { + if let dbpError = error as? DataBrokerProtectionError { + if dbpError.is404 { + return createRowFor(matched: false, result: result, error: "404 - No results") + } else { + return createRowFor(matched: false, result: result, error: "\(dbpError.title)-\(dbpError.description)") + } + } else { + return createRowFor(matched: false, result: result, error: error.localizedDescription) + } + } + + private func append(_ result: DebugScanReturnValue) -> String { + var resultsText = "" + + if let meta = result.meta{ + do { + let jsonData = try JSONSerialization.data(withJSONObject: meta, options: []) + let decoder = JSONDecoder() + let decodedMeta = try decoder.decode(Metadata.self, from: jsonData) + + for extractedResult in decodedMeta.extractResults { + resultsText.append(createRowFor(matched: extractedResult.result, result: result, extractedResult: extractedResult)) + } + } catch { + print("Error decoding JSON: \(error)") + } + } else { + print("No meta object") } + return resultsText + } + + private func createRowFor(matched: Bool, + result: DebugScanReturnValue, + error: String? = nil, + extractedResult: ExtractResult? = nil) -> String { + let matchedString = matched ? "TRUE" : "FALSE" + let profileQuery = result.brokerProfileQueryData.profileQuery + + var csvRow = "" + + csvRow.append("\(profileQuery.fullName),") // Name (input) + csvRow.append("\(profileQuery.age),") // Age (input) + csvRow.append("\(profileQuery.city),") // City (input) + csvRow.append("\(profileQuery.state),") // State (input) + + if let extractedResult = extractedResult { + csvRow.append("\(extractedResult.scrapedData.nameCSV),") // Name (scraped) + csvRow.append("\(extractedResult.scrapedData.ageCSV),") // Age (scraped) + csvRow.append("\(extractedResult.scrapedData.addressesCSV),") // Address (scraped) + csvRow.append("\(extractedResult.scrapedData.relativesCSV),") // Relatives (matched) + } else { + csvRow.append(",") // Name (scraped) + csvRow.append(",") // Age (scraped) + csvRow.append(",") // Address (scraped) + csvRow.append(",") // Relatives (scraped) + } + + csvRow.append("\(result.brokerURL),") // Broker URL + csvRow.append("\(result.brokerProfileQueryData.dataBroker.name),") // Broker Name + csvRow.append("\(profileQuery.id ?? 0)_\(result.brokerProfileQueryData.dataBroker.name),") // Screenshot name + + if let error = error { + csvRow.append("\(error),") // Error + } else { + csvRow.append(",") // Error empty + } + + if let extractedResult = extractedResult { + csvRow.append("\(extractedResult.matchedFields.joined(separator: "-")),") // matched_fields + } else { + csvRow.append(",") // matched_fields + } + + csvRow.append("\(matchedString),") // result_match + csvRow.append(",") // expected_match + csvRow.append("\n") + + return csvRow + } + + private func save(csv: String) { + do { + if let desktopPath = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first?.relativePath { + let path = desktopPath + "/PIR-Debug" + let fileName = "output.csv" + let fileURL = URL(fileURLWithPath: "\(path)/\(fileName)") + try csv.write(to: fileURL, atomically: true, encoding: .utf8) + } else { + os_log("Error getting path") + } + } catch { + os_log("Error writing to file: \(error)") + } + } + + func runJSON(jsonString: String) { if let data = jsonString.data(using: .utf8) { do { let decoder = JSONDecoder() let dataBroker = try decoder.decode(DataBroker.self, from: data) self.selectedDataBroker = dataBroker let brokerProfileQueryData = createBrokerProfileQueryData(for: dataBroker) - let runner = runnerProvider.getOperationRunner() - Task { - do { - let extractedProfiles = try await runner.scan(brokerProfileQueryData, stageCalculator: FakeStageDurationCalculator(), showWebView: true) { true } - - DispatchQueue.main.async { - if extractedProfiles.isEmpty { - self.showNoResultsAlert() - } else { - self.results = extractedProfiles + for query in brokerProfileQueryData { + Task { + do { + let extractedProfiles = try await runner.scan(query, stageCalculator: FakeStageDurationCalculator(), showWebView: true) { true } + + DispatchQueue.main.async { + for extractedProfile in extractedProfiles { + self.results.append(ScanResult(dataBroker: query.dataBroker, + profileQuery: query.profileQuery, + extractedProfile: extractedProfile)) + } } + } catch { + print("Error when scanning: \(error)") } - } catch { - showAlert(for: error) } } } catch { @@ -116,18 +368,16 @@ final class DataBrokerRunCustomJSONViewModel: ObservableObject { } } - func runOptOut(extractedProfile: ExtractedProfile) { + func runOptOut(scanResult: ScanResult) { let runner = runnerProvider.getOperationRunner() - guard let dataBroker = self.selectedDataBroker else { - print("No broker selected") - return - } - - let brokerProfileQueryData = createBrokerProfileQueryData(for: dataBroker) - + let brokerProfileQueryData = BrokerProfileQueryData( + dataBroker: scanResult.dataBroker, + profileQuery: scanResult.profileQuery, + scanOperationData: ScanOperationData(brokerId: 1, profileQueryId: 1, historyEvents: [HistoryEvent]()) + ) Task { do { - try await runner.optOut(profileQuery: brokerProfileQueryData, extractedProfile: extractedProfile, stageCalculator: FakeStageDurationCalculator(), showWebView: true) { + try await runner.optOut(profileQuery: brokerProfileQueryData, extractedProfile: scanResult.extractedProfile, stageCalculator: FakeStageDurationCalculator(), showWebView: true) { true } @@ -142,10 +392,54 @@ final class DataBrokerRunCustomJSONViewModel: ObservableObject { } } - private func createBrokerProfileQueryData(for dataBroker: DataBroker) -> BrokerProfileQueryData { - let profile = createProfile() - let fakeScanOperationData = ScanOperationData(brokerId: 0, profileQueryId: 0, historyEvents: [HistoryEvent]()) - return BrokerProfileQueryData(dataBroker: dataBroker, profileQuery: profile.profileQueries.first!, scanOperationData: fakeScanOperationData) + private func createBrokerProfileQueryData(for broker: DataBroker) -> [BrokerProfileQueryData] { + let profile: DataBrokerProtectionProfile = + .init( + names: names.map { $0.toModel() }, + addresses: addresses.map { $0.toModel() }, + phones: [String](), + birthYear: Int(birthYear) ?? 1990 + ) + let profileQueries = profile.profileQueries + var brokerProfileQueryData = [BrokerProfileQueryData]() + + var profileQueryIndex: Int64 = 1 + for profileQuery in profileQueries { + let fakeScanOperationData = ScanOperationData(brokerId: 0, profileQueryId: profileQueryIndex, historyEvents: [HistoryEvent]()) + brokerProfileQueryData.append( + .init(dataBroker: broker, profileQuery: profileQuery.with(id: profileQueryIndex), scanOperationData: fakeScanOperationData) + ) + + profileQueryIndex += 1 + } + + return brokerProfileQueryData + } + + private func createBrokerProfileQueryData() -> [BrokerProfileQueryData] { + let profile: DataBrokerProtectionProfile = + .init( + names: names.map { $0.toModel() }, + addresses: addresses.map { $0.toModel() }, + phones: [String](), + birthYear: Int(birthYear) ?? 1990 + ) + let profileQueries = profile.profileQueries + var brokerProfileQueryData = [BrokerProfileQueryData]() + + var profileQueryIndex: Int64 = 1 + for profileQuery in profileQueries { + let fakeScanOperationData = ScanOperationData(brokerId: 0, profileQueryId: profileQueryIndex, historyEvents: [HistoryEvent]()) + for broker in brokers { + brokerProfileQueryData.append( + .init(dataBroker: broker, profileQuery: profileQuery.with(id: profileQueryIndex), scanOperationData: fakeScanOperationData) + ) + } + + profileQueryIndex += 1 + } + + return brokerProfileQueryData } private func showNoResultsAlert() { @@ -166,21 +460,9 @@ final class DataBrokerRunCustomJSONViewModel: ObservableObject { } } - private func createProfile() -> DataBrokerProtectionProfile { - let names = DataBrokerProtectionProfile.Name(firstName: firstName, lastName: lastName, middleName: middle) - let addresses = DataBrokerProtectionProfile.Address(city: city, state: state) - - return DataBrokerProtectionProfile(names: [names], addresses: [addresses], phones: [String](), birthYear: Int(birthYear) ?? 1990) - } - func appVersion() -> String { AppVersion.shared.versionNumber } - - func contentScopeScriptsVersion() -> String { - // How can I return this? - return "4.59.2" - } } final class FakeStageDurationCalculator: StageDurationCalculator { @@ -367,4 +649,51 @@ extension DataBrokerProtectionError { default: return name } } + + var is404: Bool { + switch self { + case .httpError(let code): + return code == 404 + default: return false + } + } +} + +extension ScrapedData { + + var nameCSV: String { + if let name = self.name { + return name.replacingOccurrences(of: ",", with: "-") + } else if let alternativeNamesList = self.alternativeNamesList { + return alternativeNamesList.joined(separator: "/").replacingOccurrences(of: ",", with: "-") + } else { + return "" + } + } + + var ageCSV: String { + if let age = self.age { + return age + } else { + return "" + } + } + + var addressesCSV: String { + if let address = self.addressCityState { + return address + } else if let addressFull = self.addressCityStateList { + return addressFull.map { "\($0.city)-\($0.state)" }.joined(separator: "/") + } else { + return "" + } + } + + var relativesCSV: String { + if let relatives = self.relativesList { + return relatives.joined(separator: "-").replacingOccurrences(of: ",", with: "-") + } else { + return "" + } + } } diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/DebugUI/DebugScanOperation.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/DebugUI/DebugScanOperation.swift new file mode 100644 index 0000000000..94ee1b481c --- /dev/null +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/DebugUI/DebugScanOperation.swift @@ -0,0 +1,181 @@ +// +// DebugScanOperation.swift +// +// Copyright © 2023 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 WebKit +import BrowserServicesKit +import UserScript +import Common + +struct DebugScanReturnValue { + let brokerURL: String + let extractedProfiles: [ExtractedProfile] + let error: Error? + let brokerProfileQueryData: BrokerProfileQueryData + let meta: [String: Any]? + + init(brokerURL: String, + extractedProfiles: [ExtractedProfile] = [ExtractedProfile](), + error: Error? = nil, + brokerProfileQueryData: BrokerProfileQueryData, + meta: [String: Any]? = nil) { + self.brokerURL = brokerURL + self.extractedProfiles = extractedProfiles + self.error = error + self.brokerProfileQueryData = brokerProfileQueryData + self.meta = meta + } +} + +final class DebugScanOperation: DataBrokerOperation { + typealias ReturnValue = DebugScanReturnValue + typealias InputValue = Void + + let privacyConfig: PrivacyConfigurationManaging + let prefs: ContentScopeProperties + let query: BrokerProfileQueryData + let emailService: EmailServiceProtocol + let captchaService: CaptchaServiceProtocol + var webViewHandler: WebViewHandler? + var actionsHandler: ActionsHandler? + var continuation: CheckedContinuation<DebugScanReturnValue, Error>? + var extractedProfile: ExtractedProfile? + var stageCalculator: StageDurationCalculator? + private let operationAwaitTime: TimeInterval + let shouldRunNextStep: () -> Bool + var retriesCountOnError: Int = 0 + var scanURL: String? + + private let fileManager = FileManager.default + private let debugScanContentPath: String? + + init(privacyConfig: PrivacyConfigurationManaging, + prefs: ContentScopeProperties, + query: BrokerProfileQueryData, + emailService: EmailServiceProtocol = EmailService(), + captchaService: CaptchaServiceProtocol = CaptchaService(), + operationAwaitTime: TimeInterval = 3, + shouldRunNextStep: @escaping () -> Bool + ) { + self.privacyConfig = privacyConfig + self.prefs = prefs + self.query = query + self.emailService = emailService + self.captchaService = captchaService + self.operationAwaitTime = operationAwaitTime + self.shouldRunNextStep = shouldRunNextStep + if let desktopPath = fileManager.urls(for: .desktopDirectory, in: .userDomainMask).first?.relativePath { + self.debugScanContentPath = desktopPath + "/PIR-Debug" + } else { + self.debugScanContentPath = nil + } + } + + func run(inputValue: Void, + webViewHandler: WebViewHandler? = nil, + actionsHandler: ActionsHandler? = nil, + stageCalculator: StageDurationCalculator, // We do not need it for scans - for now. + showWebView: Bool) async throws -> DebugScanReturnValue { + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + Task { + await initialize(handler: webViewHandler, isFakeBroker: query.dataBroker.isFakeBroker, showWebView: showWebView) + + do { + let scanStep = try query.dataBroker.scanStep() + if let actionsHandler = actionsHandler { + self.actionsHandler = actionsHandler + } else { + self.actionsHandler = ActionsHandler(step: scanStep) + } + if self.shouldRunNextStep() { + await executeNextStep() + } else { + failed(with: DataBrokerProtectionError.cancelled) + } + } catch { + failed(with: DataBrokerProtectionError.unknown(error.localizedDescription)) + } + } + } + } + + func runNextAction(_ action: Action) async { + if action as? ExtractAction != nil { + do { + if let path = self.debugScanContentPath { + let fileName = "\(query.profileQuery.id ?? 0)_\(query.dataBroker.name)" + try await webViewHandler?.takeSnaphost(path: path + "/screenshots/", fileName: "\(fileName).png") + try await webViewHandler?.saveHTML(path: path + "/html/", fileName: "\(fileName).html") + } + } catch { + print("Error: \(error)") + } + } + + await webViewHandler?.execute(action: action, data: .userData(query.profileQuery, self.extractedProfile)) + } + + func extractedProfiles(profiles: [ExtractedProfile], meta: [String: Any]?) async { + if let scanURL = self.scanURL { + let debugScanReturnValue = DebugScanReturnValue( + brokerURL: scanURL, + extractedProfiles: profiles, + brokerProfileQueryData: query, + meta: meta + ) + complete(debugScanReturnValue) + } + + await executeNextStep() + } + + func completeWith(error: Error) async { + if let scanURL = self.scanURL { + let debugScanReturnValue = DebugScanReturnValue(brokerURL: scanURL, error: error, brokerProfileQueryData: query) + complete(debugScanReturnValue) + } + + await executeNextStep() + } + + func executeNextStep() async { + retriesCountOnError = 0 // We reset the retries on error when it is successful + os_log("SCAN Waiting %{public}f seconds...", log: .action, operationAwaitTime) + + try? await Task.sleep(nanoseconds: UInt64(operationAwaitTime) * 1_000_000_000) + + if let action = actionsHandler?.nextAction() { + os_log("Next action: %{public}@", log: .action, String(describing: action.actionType.rawValue)) + await runNextAction(action) + } else { + os_log("Releasing the web view", log: .action) + await webViewHandler?.finish() // If we executed all steps we release the web view + } + } + + func loadURL(url: URL) async { + do { + self.scanURL = url.absoluteString + try await webViewHandler?.load(url: url) + await executeNextStep() + } catch { + await completeWith(error: error) + } + } +} diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DataBrokerProtectionProfile.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DataBrokerProtectionProfile.swift index a77770d5f9..c76645a804 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DataBrokerProtectionProfile.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DataBrokerProtectionProfile.swift @@ -69,7 +69,7 @@ public struct DataBrokerProtectionProfile: Codable { } } -internal extension DataBrokerProtectionProfile { +extension DataBrokerProtectionProfile { var profileQueries: [ProfileQuery] { return addresses.flatMap { address in names.map { name in diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/ProfileQuery.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/ProfileQuery.swift index 0850e3027c..c2cbde5fa5 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/ProfileQuery.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/ProfileQuery.swift @@ -117,6 +117,21 @@ extension ProfileQuery { birthYear: birthYear, deprecated: deprecated) } + + func with(id: Int64) -> ProfileQuery { + return ProfileQuery(id: id, + firstName: firstName, + lastName: lastName, + middleName: middleName, + suffix: suffix, + city: city, + state: state, + street: street, + zipCode: zip, + phone: phone, + birthYear: birthYear, + deprecated: deprecated) + } } extension ProfileQuery: Hashable { diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/OptOutOperation.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/OptOutOperation.swift index ba59229aeb..7f12d1112f 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/OptOutOperation.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/OptOutOperation.swift @@ -99,7 +99,7 @@ final class OptOutOperation: DataBrokerOperation { } } - func extractedProfiles(profiles: [ExtractedProfile]) async { + func extractedProfiles(profiles: [ExtractedProfile], meta: [String: Any]?) async { // No - op } diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/ScanOperation.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/ScanOperation.swift index ac26510a64..06dfddd03d 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/ScanOperation.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/ScanOperation.swift @@ -87,7 +87,7 @@ final class ScanOperation: DataBrokerOperation { } } - func extractedProfiles(profiles: [ExtractedProfile]) async { + func extractedProfiles(profiles: [ExtractedProfile], meta: [String: Any]?) async { complete(profiles) await executeNextStep() } diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionFeatureTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionFeatureTests.swift index 4fe2f44458..280b30050d 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionFeatureTests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionFeatureTests.swift @@ -118,7 +118,7 @@ final class MockCSSCommunicationDelegate: CCFCommunicationDelegate { self.url = url } - func extractedProfiles(profiles: [ExtractedProfile]) { + func extractedProfiles(profiles: [ExtractedProfile], meta: [String: Any]?) async { self.profiles = profiles } diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift index 24a55ee92d..cc36c1e9de 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift @@ -214,6 +214,14 @@ final class WebViewHandlerMock: NSObject, WebViewHandler { wasExecuteJavascriptCalled = true } + func takeSnaphost(path: String, fileName: String) async throws { + + } + + func saveHTML(path: String, fileName: String) async throws { + + } + func reset() { wasInitializeWebViewCalled = false wasLoadCalledWithURL = nil From 8f594baec13c6b48634fa0361bf7a7044a5d0ecd Mon Sep 17 00:00:00 2001 From: Dax the Duck <dax@duckduckgo.com> Date: Fri, 15 Mar 2024 13:41:32 +0000 Subject: [PATCH 09/16] Update embedded files --- .../AppPrivacyConfigurationDataProvider.swift | 4 +- DuckDuckGo/ContentBlocker/macos-config.json | 68 ++++++++++++++----- 2 files changed, 52 insertions(+), 20 deletions(-) diff --git a/DuckDuckGo/ContentBlocker/AppPrivacyConfigurationDataProvider.swift b/DuckDuckGo/ContentBlocker/AppPrivacyConfigurationDataProvider.swift index 427b2983c2..1843f8fc9d 100644 --- a/DuckDuckGo/ContentBlocker/AppPrivacyConfigurationDataProvider.swift +++ b/DuckDuckGo/ContentBlocker/AppPrivacyConfigurationDataProvider.swift @@ -22,8 +22,8 @@ import BrowserServicesKit final class AppPrivacyConfigurationDataProvider: EmbeddedDataProvider { public struct Constants { - public static let embeddedDataETag = "\"b9487380b1490e67513c650e9497a28c\"" - public static let embeddedDataSHA = "79590d4f2a9713b20eb127a29f862130fdc964145917004031022beaecf80fd0" + public static let embeddedDataETag = "\"11616ebbea5b6d7731bf08d224c6b1a7\"" + public static let embeddedDataSHA = "980b19df068a45b754ed6865295960a914c3d2c3bcc2f20b9d1f8a5f2c1d68c3" } var embeddedDataEtag: String { diff --git a/DuckDuckGo/ContentBlocker/macos-config.json b/DuckDuckGo/ContentBlocker/macos-config.json index 57da7cd02e..bb74d388c6 100644 --- a/DuckDuckGo/ContentBlocker/macos-config.json +++ b/DuckDuckGo/ContentBlocker/macos-config.json @@ -1,6 +1,6 @@ { "readme": "https://github.com/duckduckgo/privacy-configuration", - "version": 1710170291349, + "version": 1710501855617, "features": { "adClickAttribution": { "readme": "https://help.duckduckgo.com/duckduckgo-help-pages/privacy/web-tracking-protections/#3rd-party-tracker-loading-protection", @@ -264,6 +264,18 @@ { "domain": "meneame.net" }, + { + "domain": "espn.com" + }, + { + "domain": "usaa.com" + }, + { + "domain": "publico.es" + }, + { + "domain": "cnbc.com" + }, { "domain": "earth.google.com" }, @@ -287,7 +299,7 @@ ] }, "state": "enabled", - "hash": "8458a9cc012a9613e1497204003be946" + "hash": "ec25d3a0b633fbc4f208f35999f4ab0e" }, "autofill": { "exceptions": [ @@ -968,8 +980,11 @@ }, "clientBrandHint": { "exceptions": [], + "settings": { + "domains": [] + }, "state": "disabled", - "hash": "728493ef7a1488e4781656d3f9db84aa" + "hash": "d35dd75140cdfe166762013e59eb076d" }, "contentBlocking": { "state": "enabled", @@ -3904,6 +3919,23 @@ } ] }, + { + "domain": "uzone.id", + "rules": [ + { + "selector": "[class^='box-ads']", + "type": "hide-empty" + }, + { + "selector": "[class^='section-ads']", + "type": "hide-empty" + }, + { + "selector": ".parallax-container", + "type": "hide-empty" + } + ] + }, { "domain": "washingtontimes.com", "rules": [ @@ -3984,7 +4016,7 @@ ] }, "state": "enabled", - "hash": "3dc2d5b9a38827f46503a3b5882c7e33" + "hash": "3098b766cd343378237605f207f4fd17" }, "exceptionHandler": { "exceptions": [ @@ -4878,6 +4910,16 @@ "state": "enabled", "settings": { "allowlistedTrackers": { + "2mdn.net": { + "rules": [ + { + "rule": "2mdn.net", + "domains": [ + "crunchyroll.com" + ] + } + ] + }, "3lift.com": { "rules": [ { @@ -6013,22 +6055,11 @@ "fwmrm.net": { "rules": [ { - "rule": "2a7e9.v.fwmrm.net/ad/g/1", + "rule": "v.fwmrm.net/ad", "domains": [ + "6play.fr", "channel4.com" ] - }, - { - "rule": "2a7e9.v.fwmrm.net/ad/l/1", - "domains": [ - "channel4.com" - ] - }, - { - "rule": "7cbf2.v.fwmrm.net/ad/g/1", - "domains": [ - "6play.fr" - ] } ] }, @@ -6197,6 +6228,7 @@ "domains": [ "arkadium.com", "bloomberg.com", + "crunchyroll.com", "gamak.tv", "games.washingtonpost.com", "metro.co.uk", @@ -7946,7 +7978,7 @@ "domain": "sundancecatalog.com" } ], - "hash": "4325ed151d126936fbeb9a608d77da86" + "hash": "1bbefc586e08d2c42b5db17ed95cc8e5" }, "trackingCookies1p": { "settings": { From ecdd9ac00618cdf9cb3314be0f86548dc9f37855 Mon Sep 17 00:00:00 2001 From: Dax the Duck <dax@duckduckgo.com> Date: Fri, 15 Mar 2024 13:41:32 +0000 Subject: [PATCH 10/16] Set marketing version to 1.80.0 --- Configuration/Version.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Configuration/Version.xcconfig b/Configuration/Version.xcconfig index da3c8e7045..7a0581611b 100644 --- a/Configuration/Version.xcconfig +++ b/Configuration/Version.xcconfig @@ -1 +1 @@ -MARKETING_VERSION = 1.79.0 +MARKETING_VERSION = 1.80.0 From 05d80b76af5b65443a8dcf923a25e659388221b5 Mon Sep 17 00:00:00 2001 From: Dax the Duck <dax@duckduckgo.com> Date: Fri, 15 Mar 2024 13:52:33 +0000 Subject: [PATCH 11/16] Bump version to 1.80.0 (144) --- Configuration/BuildNumber.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Configuration/BuildNumber.xcconfig b/Configuration/BuildNumber.xcconfig index 9a14499d7a..db702d8aab 100644 --- a/Configuration/BuildNumber.xcconfig +++ b/Configuration/BuildNumber.xcconfig @@ -1 +1 @@ -CURRENT_PROJECT_VERSION = 143 +CURRENT_PROJECT_VERSION = 144 From 8fdbc689ac386571c186ff2f69a67c5d1e051b83 Mon Sep 17 00:00:00 2001 From: Dominik Kapusta <dkapusta@duckduckgo.com> Date: Fri, 15 Mar 2024 15:08:14 +0100 Subject: [PATCH 12/16] Fix syntax in Asana actions --- .github/actions/asana-create-action-item/action.yml | 2 +- .github/actions/asana-log-message/action.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/asana-create-action-item/action.yml b/.github/actions/asana-create-action-item/action.yml index f3c5455006..7f4ee3f3c2 100644 --- a/.github/actions/asana-create-action-item/action.yml +++ b/.github/actions/asana-create-action-item/action.yml @@ -44,7 +44,7 @@ runs: task-url: ${{ inputs.release-task-url }} - id: get-asana-user-id - if: github.event_name != "schedule" + if: github.event_name != 'schedule' uses: duckduckgo/apple-infra/actions/asana-get-user-id-for-github-handle@main with: access-token: ${{ inputs.access-token }} diff --git a/.github/actions/asana-log-message/action.yml b/.github/actions/asana-log-message/action.yml index 86940852bf..288fd832ba 100644 --- a/.github/actions/asana-log-message/action.yml +++ b/.github/actions/asana-log-message/action.yml @@ -30,7 +30,7 @@ runs: task-url: ${{ inputs.task-url }} - id: get-asana-user-id - if: github.event_name != "schedule" + if: github.event_name != 'schedule' uses: duckduckgo/apple-infra/actions/asana-get-user-id-for-github-handle@main with: access-token: ${{ inputs.access-token }} From 90cff6bc14ff3f80997ad1251d37464397b3bcf7 Mon Sep 17 00:00:00 2001 From: Dax the Duck <dax@duckduckgo.com> Date: Fri, 15 Mar 2024 14:28:41 +0000 Subject: [PATCH 13/16] Bump version to 1.80.0 (145) --- Configuration/BuildNumber.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Configuration/BuildNumber.xcconfig b/Configuration/BuildNumber.xcconfig index db702d8aab..b11c07fc54 100644 --- a/Configuration/BuildNumber.xcconfig +++ b/Configuration/BuildNumber.xcconfig @@ -1 +1 @@ -CURRENT_PROJECT_VERSION = 144 +CURRENT_PROJECT_VERSION = 145 From 793d16e629d10198642340da0ad0530fd0448845 Mon Sep 17 00:00:00 2001 From: Daniel Bernal <dbernal@duckduckgo.com> Date: Sat, 16 Mar 2024 00:35:41 +0100 Subject: [PATCH 14/16] Bump BSK (#2435) Task/Issue URL: https://app.asana.com/0/72649045549333/1206350282230222/f **Description**: Bump BSK **Steps to test this PR**: 1. iOS Changes Only <!-- Tagging instructions If this PR isn't ready to be merged for whatever reason it should be marked with the `DO NOT MERGE` label (particularly if it's a draft) If it's pending Product Review/PFR, please add the `Pending Product Review` label. If at any point it isn't actively being worked on/ready for review/otherwise moving forward (besides the above PR/PFR exception) strongly consider closing it (or not opening it in the first place). If you decide not to close it, make sure it's labelled to make it clear the PRs state and comment with more information. --> --- ###### Internal references: [Pull Request Review Checklist](https://app.asana.com/0/1202500774821704/1203764234894239/f) [Software Engineering Expectations](https://app.asana.com/0/59792373528535/199064865822552) [Technical Design Template](https://app.asana.com/0/59792373528535/184709971311943) [Pull Request Documentation](https://app.asana.com/0/1202500774821704/1204012835277482/f) --- DuckDuckGo.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- LocalPackages/DataBrokerProtection/Package.swift | 2 +- LocalPackages/NetworkProtectionMac/Package.swift | 2 +- LocalPackages/SubscriptionUI/Package.swift | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 23d4cdecf1..c985be5176 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -13749,7 +13749,7 @@ repositoryURL = "https://github.com/duckduckgo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 124.1.0; + version = 125.0.0; }; }; AA06B6B52672AF8100F541C5 /* XCRemoteSwiftPackageReference "Sparkle" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5d9e06d99f..24e42fd976 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/BrowserServicesKit", "state" : { - "revision" : "bcafd206465427c560f9f581def57d8eef53748c", - "version" : "124.1.0" + "revision" : "ac2127a26f75b2aa293f6036bcdd2bc241d09819", + "version" : "125.0.0" } }, { diff --git a/LocalPackages/DataBrokerProtection/Package.swift b/LocalPackages/DataBrokerProtection/Package.swift index e07c2c873c..d428da60b2 100644 --- a/LocalPackages/DataBrokerProtection/Package.swift +++ b/LocalPackages/DataBrokerProtection/Package.swift @@ -29,7 +29,7 @@ let package = Package( targets: ["DataBrokerProtection"]) ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "124.1.0"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "125.0.0"), .package(path: "../PixelKit"), .package(path: "../SwiftUIExtensions"), .package(path: "../XPCHelper"), diff --git a/LocalPackages/NetworkProtectionMac/Package.swift b/LocalPackages/NetworkProtectionMac/Package.swift index 5c07540155..39f13d9fe0 100644 --- a/LocalPackages/NetworkProtectionMac/Package.swift +++ b/LocalPackages/NetworkProtectionMac/Package.swift @@ -31,7 +31,7 @@ let package = Package( .library(name: "NetworkProtectionUI", targets: ["NetworkProtectionUI"]) ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "124.1.0"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "125.0.0"), .package(path: "../XPCHelper"), .package(path: "../SwiftUIExtensions"), .package(path: "../LoginItems"), diff --git a/LocalPackages/SubscriptionUI/Package.swift b/LocalPackages/SubscriptionUI/Package.swift index c29571c2ce..6ffb49191b 100644 --- a/LocalPackages/SubscriptionUI/Package.swift +++ b/LocalPackages/SubscriptionUI/Package.swift @@ -12,7 +12,7 @@ let package = Package( targets: ["SubscriptionUI"]), ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "124.1.0"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "125.0.0"), .package(path: "../SwiftUIExtensions") ], targets: [ From 5d7b4bc272c7f06188e129f807d44dc99f5b6894 Mon Sep 17 00:00:00 2001 From: Juan Manuel Pereira <juanmanuel.pereira1@gmail.com> Date: Sun, 17 Mar 2024 13:50:36 -0300 Subject: [PATCH 15/16] DBP: Implement event pixels (#2408) --- DuckDuckGo/DBP/DBPHomeViewController.swift | 6 +- .../Model/HistoryEvent.swift | 10 + ...taBrokerProfileQueryOperationManager.swift | 7 +- .../OperationPreferredDateCalculator.swift | 5 +- .../DataBrokerProtectionEventPixels.swift | 142 ++++++++ .../Pixels/DataBrokerProtectionPixels.swift | 29 +- .../DataBrokerProtectionProcessor.swift | 4 + ...kerProfileQueryOperationManagerTests.swift | 17 + ...DataBrokerProtectionEventPixelsTests.swift | 318 ++++++++++++++++++ 9 files changed, 531 insertions(+), 7 deletions(-) create mode 100644 LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionEventPixels.swift create mode 100644 LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionEventPixelsTests.swift diff --git a/DuckDuckGo/DBP/DBPHomeViewController.swift b/DuckDuckGo/DBP/DBPHomeViewController.swift index b64fc689e8..9313c0530a 100644 --- a/DuckDuckGo/DBP/DBPHomeViewController.swift +++ b/DuckDuckGo/DBP/DBPHomeViewController.swift @@ -191,7 +191,11 @@ public class DataBrokerProtectionPixelsHandler: EventMapping<DataBrokerProtectio .dataBrokerProtectionNotificationOpenedAllRecordsRemoved, .dailyActiveUser, .weeklyActiveUser, - .monthlyActiveUser: + .monthlyActiveUser, + .weeklyReportScanning, + .weeklyReportRemovals, + .scanningEventNewMatch, + .scanningEventReAppearance: Pixel.fire(.pixelKitEvent(event)) } } diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/HistoryEvent.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/HistoryEvent.swift index 111d6dad23..9ed693822b 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/HistoryEvent.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/HistoryEvent.swift @@ -27,6 +27,7 @@ public struct HistoryEvent: Identifiable, Sendable { case optOutRequested case optOutConfirmed case scanStarted + case reAppearence } public let extractedProfileId: Int64? @@ -50,4 +51,13 @@ public struct HistoryEvent: Identifiable, Sendable { self.date = date self.type = type } + + func matchesFound() -> Int { + switch type { + case .matchesFound(let matchesFound): + return matchesFound + default: + return 0 + } + } } diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerProfileQueryOperationManager.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerProfileQueryOperationManager.swift index 58352c3fc3..f5c29ae8b7 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerProfileQueryOperationManager.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerProfileQueryOperationManager.swift @@ -117,7 +117,8 @@ struct DataBrokerProfileQueryOperationManager: OperationsManager { notificationCenter.post(name: DataBrokerProtectionNotifications.didFinishScan, object: brokerProfileQueryData.dataBroker.name) } - let stageCalculator = DataBrokerProtectionStageDurationCalculator(dataBroker: brokerProfileQueryData.dataBroker.url, handler: pixelHandler) + let eventPixels = DataBrokerProtectionEventPixels(database: database, handler: pixelHandler) + let stageCalculator = DataBrokerProtectionStageDurationCalculator(dataBroker: brokerProfileQueryData.dataBroker.name, handler: pixelHandler) do { let event = HistoryEvent(brokerId: brokerId, profileQueryId: profileQueryId, type: .scanStarted) @@ -141,6 +142,9 @@ struct DataBrokerProfileQueryOperationManager: OperationsManager { if doesProfileExistsInDatabase, let alreadyInDatabaseProfile = extractedProfilesForBroker.first(where: { $0.identifier == extractedProfile.identifier }), let id = alreadyInDatabaseProfile.id { // If it was removed in the past but was found again when scanning, it means it appearead again, so we reset the remove date. if alreadyInDatabaseProfile.removedDate != nil { + let reAppereanceEvent = HistoryEvent(extractedProfileId: extractedProfile.id, brokerId: brokerId, profileQueryId: profileQueryId, type: .reAppearence) + eventPixels.fireReAppereanceEventPixel() + database.add(reAppereanceEvent) database.updateRemovedDate(nil, on: id) } @@ -148,6 +152,7 @@ struct DataBrokerProfileQueryOperationManager: OperationsManager { } else { // If it's a new found profile, we'd like to opt-out ASAP // If this broker has a parent opt out, we set the preferred date to nil, as we will only perform the operation within the parent. + eventPixels.fireNewMatchEventPixel() let broker = brokerProfileQueryData.dataBroker let preferredRunOperation: Date? = broker.performsOptOutWithinParent() ? nil : Date() diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/OperationPreferredDateCalculator.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/OperationPreferredDateCalculator.swift index f5d2063be0..ca42eaedda 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/OperationPreferredDateCalculator.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/OperationPreferredDateCalculator.swift @@ -41,14 +41,13 @@ struct OperationPreferredDateCalculator { } switch lastEvent.type { - case .optOutConfirmed: if isDeprecated { return nil } else { return Date().addingTimeInterval(schedulingConfig.maintenanceScan.hoursToSeconds) } - case .noMatchFound, .matchesFound: + case .noMatchFound, .matchesFound, .reAppearence: return Date().addingTimeInterval(schedulingConfig.maintenanceScan.hoursToSeconds) case .error: return Date().addingTimeInterval(schedulingConfig.retryError.hoursToSeconds) @@ -70,7 +69,7 @@ struct OperationPreferredDateCalculator { } switch lastEvent.type { - case .matchesFound: + case .matchesFound, .reAppearence: if let extractedProfileID = extractedProfileID, shouldScheduleNewOptOut(events: historyEvents, extractedProfileId: extractedProfileID, schedulingConfig: schedulingConfig) { diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionEventPixels.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionEventPixels.swift new file mode 100644 index 0000000000..fd0a382703 --- /dev/null +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionEventPixels.swift @@ -0,0 +1,142 @@ +// +// DataBrokerProtectionEventPixels.swift +// +// Copyright © 2023 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 Common +import BrowserServicesKit +import PixelKit + +protocol DataBrokerProtectionEventPixelsRepository { + func markWeeklyPixelSent() + + func getLatestWeeklyPixel() -> Date? +} + +final class DataBrokerProtectionEventPixelsUserDefaults: DataBrokerProtectionEventPixelsRepository { + + enum Consts { + static let weeklyPixelKey = "macos.browser.data-broker-protection.eventsWeeklyPixelKey" + } + + private let userDefaults: UserDefaults + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + func markWeeklyPixelSent() { + userDefaults.set(Date(), forKey: Consts.weeklyPixelKey) + } + + func getLatestWeeklyPixel() -> Date? { + userDefaults.object(forKey: Consts.weeklyPixelKey) as? Date + } +} + +final class DataBrokerProtectionEventPixels { + + private let database: DataBrokerProtectionRepository + private let repository: DataBrokerProtectionEventPixelsRepository + private let handler: EventMapping<DataBrokerProtectionPixels> + private let calendar = Calendar.current + + init(database: DataBrokerProtectionRepository, + repository: DataBrokerProtectionEventPixelsRepository = DataBrokerProtectionEventPixelsUserDefaults(), + handler: EventMapping<DataBrokerProtectionPixels>) { + self.database = database + self.repository = repository + self.handler = handler + } + + func tryToFireWeeklyPixels() { + if shouldWeFireWeeklyPixel() { + fireWeeklyReportPixels() + repository.markWeeklyPixelSent() + } + } + + func fireNewMatchEventPixel() { + handler.fire(.scanningEventNewMatch) + } + + func fireReAppereanceEventPixel() { + handler.fire(.scanningEventReAppearance) + } + + private func shouldWeFireWeeklyPixel() -> Bool { + guard let lastPixelFiredDate = repository.getLatestWeeklyPixel() else { + return true // Last pixel fired date is not present. We should fire it + } + + return didWeekPassedBetweenDates(start: lastPixelFiredDate, end: Date()) + } + + private func fireWeeklyReportPixels() { + let data = database.fetchAllBrokerProfileQueryData() + let dataInThePastWeek = data.filter(hadScanThisWeek(_:)) + + var newMatchesFoundInTheLastWeek = 0 + var reAppereancesInTheLastWeek = 0 + var removalsInTheLastWeek = 0 + + for query in data { + let allHistoryEventsForQuery = query.scanOperationData.historyEvents + query.optOutOperationsData.flatMap { $0.historyEvents } + let historyEventsInThePastWeek = allHistoryEventsForQuery.filter { + !didWeekPassedBetweenDates(start: $0.date, end: Date()) + } + let newMatches = historyEventsInThePastWeek.reduce(0, { result, next in + return result + next.matchesFound() + }) + let reAppereances = historyEventsInThePastWeek.filter { $0.type == .reAppearence }.count + let removals = historyEventsInThePastWeek.filter { $0.type == .optOutConfirmed }.count + + newMatchesFoundInTheLastWeek += newMatches + reAppereancesInTheLastWeek += reAppereances + removalsInTheLastWeek += removals + } + + let totalBrokers = Dictionary(grouping: data, by: { $0.dataBroker.url }).count + let totalBrokersInTheLastWeek = Dictionary(grouping: dataInThePastWeek, by: { $0.dataBroker.url }).count + var percentageOfBrokersScanned: Int + + if totalBrokers == 0 { + percentageOfBrokersScanned = 0 + } else { + percentageOfBrokersScanned = (totalBrokersInTheLastWeek * 100) / totalBrokers + } + + handler.fire(.weeklyReportScanning(hadNewMatch: newMatchesFoundInTheLastWeek > 0, hadReAppereance: reAppereancesInTheLastWeek > 0, scanCoverage: percentageOfBrokersScanned)) + handler.fire(.weeklyReportRemovals(removals: removalsInTheLastWeek)) + } + + private func hadScanThisWeek(_ brokerProfileQuery: BrokerProfileQueryData) -> Bool { + return brokerProfileQuery.scanOperationData.historyEvents.contains { historyEvent in + !didWeekPassedBetweenDates(start: historyEvent.date, end: Date()) + } + } + + private func didWeekPassedBetweenDates(start: Date, end: Date) -> Bool { + let components = calendar.dateComponents([.day], from: start, to: end) + + if let differenceInDays = components.day { + return differenceInDays >= 7 + } else { + return false + } + } +} diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionPixels.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionPixels.swift index e7198fe713..9663bbae4d 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionPixels.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionPixels.swift @@ -54,6 +54,10 @@ public enum DataBrokerProtectionPixels { static let pattern = "pattern" static let isParent = "is_parent" static let actionIDKey = "action_id" + static let hadNewMatch = "had_new_match" + static let hadReAppereance = "had_re-appearance" + static let scanCoverage = "scan_coverage" + static let removals = "removals" } case error(error: DataBrokerProtectionError, dataBroker: String) @@ -117,6 +121,12 @@ public enum DataBrokerProtectionPixels { case dailyActiveUser case weeklyActiveUser case monthlyActiveUser + + // KPIs - events + case weeklyReportScanning(hadNewMatch: Bool, hadReAppereance: Bool, scanCoverage: Int) + case weeklyReportRemovals(removals: Int) + case scanningEventNewMatch + case scanningEventReAppearance } extension DataBrokerProtectionPixels: PixelKitEvent { @@ -190,6 +200,11 @@ extension DataBrokerProtectionPixels: PixelKitEvent { case .dailyActiveUser: return "m_mac_dbp_engagement_dau" case .weeklyActiveUser: return "m_mac_dbp_engagement_wau" case .monthlyActiveUser: return "m_mac_dbp_engagement_mau" + + case .weeklyReportScanning: return "m_mac_dbp_event_weekly-report_scanning" + case .weeklyReportRemovals: return "m_mac_dbp_event_weekly-report_removals" + case .scanningEventNewMatch: return "m_mac_dbp_event_scanning-events_new-match" + case .scanningEventReAppearance: return "m_mac_dbp_event_scanning-events_re-appearance" } } @@ -251,6 +266,10 @@ extension DataBrokerProtectionPixels: PixelKitEvent { } return params + case .weeklyReportScanning(let hadNewMatch, let hadReAppereance, let scanCoverage): + return [Consts.hadNewMatch: hadNewMatch.description, Consts.hadReAppereance: hadReAppereance.description, Consts.scanCoverage: scanCoverage.description] + case .weeklyReportRemovals(let removals): + return [Consts.removals: String(removals)] case .backgroundAgentStarted, .backgroundAgentRunOperationsAndStartSchedulerIfPossible, .backgroundAgentRunOperationsAndStartSchedulerIfPossibleNoSavedProfile, @@ -266,7 +285,9 @@ extension DataBrokerProtectionPixels: PixelKitEvent { .dataBrokerProtectionNotificationOpenedAllRecordsRemoved, .dailyActiveUser, .weeklyActiveUser, - .monthlyActiveUser: + .monthlyActiveUser, + .scanningEventNewMatch, + .scanningEventReAppearance: return [:] case .ipcServerRegister, .ipcServerStartScheduler, @@ -341,7 +362,11 @@ public class DataBrokerProtectionPixelsHandler: EventMapping<DataBrokerProtectio .dataBrokerProtectionNotificationOpenedAllRecordsRemoved, .dailyActiveUser, .weeklyActiveUser, - .monthlyActiveUser: + .monthlyActiveUser, + .weeklyReportScanning, + .weeklyReportRemovals, + .scanningEventNewMatch, + .scanningEventReAppearance: PixelKit.fire(event) } diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionProcessor.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionProcessor.swift index a06a9a3b92..d6971d1a80 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionProcessor.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionProcessor.swift @@ -33,6 +33,7 @@ final class DataBrokerProtectionProcessor { private var pixelHandler: EventMapping<DataBrokerProtectionPixels> private let userNotificationService: DataBrokerProtectionUserNotificationService private let engagementPixels: DataBrokerProtectionEngagementPixels + private let eventPixels: DataBrokerProtectionEventPixels init(database: DataBrokerProtectionRepository, config: SchedulerConfig, @@ -50,6 +51,7 @@ final class DataBrokerProtectionProcessor { self.operationQueue.maxConcurrentOperationCount = config.concurrentOperationsDifferentBrokers self.userNotificationService = userNotificationService self.engagementPixels = DataBrokerProtectionEngagementPixels(database: database, handler: pixelHandler) + self.eventPixels = DataBrokerProtectionEventPixels(database: database, handler: pixelHandler) } // MARK: - Public functions @@ -115,6 +117,8 @@ final class DataBrokerProtectionProcessor { // This will fire the DAU/WAU/MAU pixels, engagementPixels.fireEngagementPixel() + // This will try to fire the event weekly report pixels + eventPixels.tryToFireWeeklyPixels() let brokersProfileData = database.fetchAllBrokerProfileQueryData() let dataBrokerOperationCollections = createDataBrokerOperationCollections(from: brokersProfileData, diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProfileQueryOperationManagerTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProfileQueryOperationManagerTests.swift index 08796454c7..70619e8736 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProfileQueryOperationManagerTests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProfileQueryOperationManagerTests.swift @@ -974,6 +974,10 @@ extension ScanOperationData { historyEvents: [HistoryEvent]() ) } + + static func mockWith(historyEvents: [HistoryEvent]) -> ScanOperationData { + ScanOperationData(brokerId: 1, profileQueryId: 1, historyEvents: historyEvents) + } } extension OptOutOperationData { @@ -1035,6 +1039,19 @@ extension DataBroker { ) ) } + + static func mockWithURL(_ url: String) -> DataBroker { + .init(name: "Test", + url: url, + steps: [Step](), + version: "1.0", + schedulingConfig: DataBrokerScheduleConfig( + retryError: 0, + confirmOptOutScan: 0, + maintenanceScan: 0 + ) + ) + } } extension ProfileQuery { diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionEventPixelsTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionEventPixelsTests.swift new file mode 100644 index 0000000000..4c5a93563b --- /dev/null +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionEventPixelsTests.swift @@ -0,0 +1,318 @@ +// +// DataBrokerProtectionEventPixelsTests.swift +// +// Copyright © 2023 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 +import Foundation +@testable import DataBrokerProtection + +final class DataBrokerProtectionEventPixelsTests: XCTestCase { + + let database = MockDatabase() + let repository = MockDataBrokerProtectionEventPixelsRepository() + let handler = MockDataBrokerProtectionPixelsHandler() + let calendar = Calendar.current + + override func tearDown() { + handler.clear() + repository.clear() + } + + func testWhenFireNewMatchEventPixelIsCalled_thenCorrectPixelIsFired() { + let sut = DataBrokerProtectionEventPixels(database: database, repository: repository, handler: handler) + + sut.fireNewMatchEventPixel() + + XCTAssertEqual( + MockDataBrokerProtectionPixelsHandler.lastPixelsFired.last!.name, + DataBrokerProtectionPixels.scanningEventNewMatch.name + ) + } + + func testWhenFireReAppereanceEventPixelIsCalled_thenCorrectPixelIsFired() { + let sut = DataBrokerProtectionEventPixels(database: database, repository: repository, handler: handler) + + sut.fireReAppereanceEventPixel() + + XCTAssertEqual( + MockDataBrokerProtectionPixelsHandler.lastPixelsFired.last!.name, + DataBrokerProtectionPixels.scanningEventReAppearance.name + ) + } + + func testWhenReportWasFiredInTheLastWeek_thenWeDoNotFireWeeklyPixels() { + repository.customGetLatestWeeklyPixel = Date().yesterday + let sut = DataBrokerProtectionEventPixels(database: database, repository: repository, handler: handler) + + sut.tryToFireWeeklyPixels() + + XCTAssertFalse(repository.wasMarkWeeklyPixelSentCalled) + } + + func testWhenReportWasNotFiredInTheLastWeek_thenWeFireWeeklyPixels() { + guard let eightDaysSinceToday = Calendar.current.date(byAdding: .day, value: -8, to: Date()) else { + XCTFail("This should no throw") + return + } + + repository.customGetLatestWeeklyPixel = eightDaysSinceToday + let sut = DataBrokerProtectionEventPixels(database: database, repository: repository, handler: handler) + + sut.tryToFireWeeklyPixels() + + XCTAssertTrue(repository.wasMarkWeeklyPixelSentCalled) + } + + func testWhenLastWeeklyPixelIsNil_thenWeFireWeeklyPixels() { + repository.customGetLatestWeeklyPixel = nil + let sut = DataBrokerProtectionEventPixels(database: database, repository: repository, handler: handler) + + sut.tryToFireWeeklyPixels() + + XCTAssertTrue(repository.wasMarkWeeklyPixelSentCalled) + } + + func testWhenReAppereanceOcurredInTheLastWeek_thenReAppereanceFlagIsTrue() { + guard let sixDaysSinceToday = Calendar.current.date(byAdding: .day, value: -6, to: Date()) else { + XCTFail("This should no throw") + return + } + + let reAppereanceThisWeekEvent = HistoryEvent(brokerId: 1, profileQueryId: 1, type: .reAppearence, date: sixDaysSinceToday) + let dataBrokerProfileQueryWithReAppereance: [BrokerProfileQueryData] = [ + .init(dataBroker: .mock, + profileQuery: .mock, + scanOperationData: .mockWith(historyEvents: [reAppereanceThisWeekEvent])) + ] + let sut = DataBrokerProtectionEventPixels(database: database, repository: repository, handler: handler) + database.brokerProfileQueryDataToReturn = dataBrokerProfileQueryWithReAppereance + repository.customGetLatestWeeklyPixel = nil + + sut.tryToFireWeeklyPixels() + + let weeklyReportScanningPixel = MockDataBrokerProtectionPixelsHandler.lastPixelsFired.first! + let hadReAppereance = weeklyReportScanningPixel.params!["had_re-appearance"]! + + XCTAssertTrue(Bool(hadReAppereance)!) + } + + func testWhenReAppereanceDidNotOcurrInTheLastWeek_thenReAppereanceFlagIsFalse() { + guard let eighDaysSinceToday = Calendar.current.date(byAdding: .day, value: -8, to: Date()) else { + XCTFail("This should no throw") + return + } + + let reAppereanceThisWeekEvent = HistoryEvent(brokerId: 1, profileQueryId: 1, type: .reAppearence, date: eighDaysSinceToday) + let dataBrokerProfileQueryWithReAppereance: [BrokerProfileQueryData] = [ + .init(dataBroker: .mock, + profileQuery: .mock, + scanOperationData: .mockWith(historyEvents: [reAppereanceThisWeekEvent])) + ] + let sut = DataBrokerProtectionEventPixels(database: database, repository: repository, handler: handler) + database.brokerProfileQueryDataToReturn = dataBrokerProfileQueryWithReAppereance + repository.customGetLatestWeeklyPixel = nil + + sut.tryToFireWeeklyPixels() + + let weeklyReportScanningPixel = MockDataBrokerProtectionPixelsHandler.lastPixelsFired.first! + let hadReAppereance = weeklyReportScanningPixel.params!["had_re-appearance"]! + + XCTAssertFalse(Bool(hadReAppereance)!) + } + + func testWhenNoMatchesHappendInTheLastWeek_thenHadNewMatchFlagIsFalse() { + guard let eighDaysSinceToday = Calendar.current.date(byAdding: .day, value: -8, to: Date()) else { + XCTFail("This should no throw") + return + } + + let newMatchesPriorToThisWeekEvent = HistoryEvent(brokerId: 1, profileQueryId: 1, type: .matchesFound(count: 2), date: eighDaysSinceToday) + let dataBrokerProfileQueryWithMatches: [BrokerProfileQueryData] = [ + .init(dataBroker: .mock, + profileQuery: .mock, + scanOperationData: .mockWith(historyEvents: [newMatchesPriorToThisWeekEvent])) + ] + let sut = DataBrokerProtectionEventPixels(database: database, repository: repository, handler: handler) + database.brokerProfileQueryDataToReturn = dataBrokerProfileQueryWithMatches + repository.customGetLatestWeeklyPixel = nil + + sut.tryToFireWeeklyPixels() + + let weeklyReportScanningPixel = MockDataBrokerProtectionPixelsHandler.lastPixelsFired.first! + let hadNewMatch = weeklyReportScanningPixel.params!["had_new_match"]! + + XCTAssertFalse(Bool(hadNewMatch)!) + } + + func testWhenMatchesHappendInTheLastWeek_thenHadNewMatchFlagIsTrue() { + guard let sixDaysSinceToday = Calendar.current.date(byAdding: .day, value: -6, to: Date()) else { + XCTFail("This should no throw") + return + } + + let newMatchesThisWeekEvent = HistoryEvent(brokerId: 1, profileQueryId: 1, type: .matchesFound(count: 2), date: sixDaysSinceToday) + let dataBrokerProfileQueryWithMatches: [BrokerProfileQueryData] = [ + .init(dataBroker: .mock, + profileQuery: .mock, + scanOperationData: .mockWith(historyEvents: [newMatchesThisWeekEvent])) + ] + let sut = DataBrokerProtectionEventPixels(database: database, repository: repository, handler: handler) + database.brokerProfileQueryDataToReturn = dataBrokerProfileQueryWithMatches + repository.customGetLatestWeeklyPixel = nil + + sut.tryToFireWeeklyPixels() + + let weeklyReportScanningPixel = MockDataBrokerProtectionPixelsHandler.lastPixelsFired.first! + let hadNewMatch = weeklyReportScanningPixel.params!["had_new_match"]! + + XCTAssertTrue(Bool(hadNewMatch)!) + } + + func testWhenNoRemovalsHappendInTheLastWeek_thenRemovalsCountIsZero() { + guard let eighDaysSinceToday = Calendar.current.date(byAdding: .day, value: -8, to: Date()) else { + XCTFail("This should no throw") + return + } + + let removalsPriorToThisWeekEvent = HistoryEvent(brokerId: 1, profileQueryId: 1, type: .optOutConfirmed, date: eighDaysSinceToday) + let dataBrokerProfileQueryWithRemovals: [BrokerProfileQueryData] = [ + .init(dataBroker: .mock, + profileQuery: .mock, + scanOperationData: .mockWith(historyEvents: [removalsPriorToThisWeekEvent])) + ] + let sut = DataBrokerProtectionEventPixels(database: database, repository: repository, handler: handler) + database.brokerProfileQueryDataToReturn = dataBrokerProfileQueryWithRemovals + repository.customGetLatestWeeklyPixel = nil + + sut.tryToFireWeeklyPixels() + + let weeklyReportScanningPixel = MockDataBrokerProtectionPixelsHandler.lastPixelsFired.last! + let removals = weeklyReportScanningPixel.params!["removals"]! + + XCTAssertEqual("0", removals) + } + + func testWhenRemovalsHappendInTheLastWeek_thenRemovalsCountIsCorrect() { + guard let sixDaysSinceToday = Calendar.current.date(byAdding: .day, value: -6, to: Date()) else { + XCTFail("This should no throw") + return + } + + let removalThisWeekEventOne = HistoryEvent(brokerId: 1, profileQueryId: 1, type: .optOutConfirmed, date: sixDaysSinceToday) + let removalThisWeekEventTwo = HistoryEvent(brokerId: 1, profileQueryId: 1, type: .optOutConfirmed, date: sixDaysSinceToday) + let dataBrokerProfileQueryWithRemovals: [BrokerProfileQueryData] = [ + .init(dataBroker: .mock, + profileQuery: .mock, + scanOperationData: .mockWith(historyEvents: [removalThisWeekEventOne, removalThisWeekEventTwo])) + ] + let sut = DataBrokerProtectionEventPixels(database: database, repository: repository, handler: handler) + database.brokerProfileQueryDataToReturn = dataBrokerProfileQueryWithRemovals + repository.customGetLatestWeeklyPixel = nil + + sut.tryToFireWeeklyPixels() + + let weeklyReportScanningPixel = MockDataBrokerProtectionPixelsHandler.lastPixelsFired.last! + let removals = weeklyReportScanningPixel.params!["removals"]! + + XCTAssertEqual("2", removals) + } + + func testWhenNoHistoryEventsHappenedInTheLastWeek_thenBrokersScannedIsZero() { + guard let eighDaysSinceToday = Calendar.current.date(byAdding: .day, value: -8, to: Date()) else { + XCTFail("This should no throw") + return + } + + let eventOne = HistoryEvent(brokerId: 1, profileQueryId: 1, type: .optOutStarted, date: eighDaysSinceToday) + let eventTwo = HistoryEvent(brokerId: 1, profileQueryId: 1, type: .error(error: .cancelled), date: eighDaysSinceToday) + let eventThree = HistoryEvent(brokerId: 1, profileQueryId: 1, type: .scanStarted, date: eighDaysSinceToday) + let eventFour = HistoryEvent(brokerId: 1, profileQueryId: 1, type: .noMatchFound, date: eighDaysSinceToday) + let dataBrokerProfileQueries: [BrokerProfileQueryData] = [ + .init(dataBroker: .mock, + profileQuery: .mock, + scanOperationData: .mockWith(historyEvents: [eventOne, eventTwo, eventThree, eventFour])) + ] + let sut = DataBrokerProtectionEventPixels(database: database, repository: repository, handler: handler) + database.brokerProfileQueryDataToReturn = dataBrokerProfileQueries + repository.customGetLatestWeeklyPixel = nil + + sut.tryToFireWeeklyPixels() + + let weeklyReportScanningPixel = MockDataBrokerProtectionPixelsHandler.lastPixelsFired.first! + let scanCoverage = weeklyReportScanningPixel.params!["scan_coverage"]! + + XCTAssertEqual("0", scanCoverage) + } + + func testWhenHistoryEventsHappenedInTheLastWeek_thenBrokersScannedIsMoreThanZero() { + guard let eighDaysSinceToday = Calendar.current.date(byAdding: .day, value: -8, to: Date()) else { + XCTFail("This should no throw") + return + } + + guard let sixDaysSinceToday = Calendar.current.date(byAdding: .day, value: -6, to: Date()) else { + XCTFail("This should no throw") + return + } + + let eventOne = HistoryEvent(brokerId: 1, profileQueryId: 1, type: .scanStarted, date: eighDaysSinceToday) + let eventTwo = HistoryEvent(brokerId: 1, profileQueryId: 1, type: .scanStarted, date: sixDaysSinceToday) + let dataBrokerProfileQueries: [BrokerProfileQueryData] = [ + .init(dataBroker: .mockWithURL("www.brokerone.com"), + profileQuery: .mock, + scanOperationData: .mockWith(historyEvents: [eventOne])), + .init(dataBroker: .mockWithURL("www.brokertwo.com"), + profileQuery: .mock, + scanOperationData: .mockWith(historyEvents: [eventOne])), + .init(dataBroker: .mockWithURL("www.brokerthree.com"), + profileQuery: .mock, + scanOperationData: .mockWith(historyEvents: [eventTwo])), + .init(dataBroker: .mockWithURL("www.brokerfour.com"), + profileQuery: .mock, + scanOperationData: .mockWith(historyEvents: [eventTwo])) + ] + let sut = DataBrokerProtectionEventPixels(database: database, repository: repository, handler: handler) + database.brokerProfileQueryDataToReturn = dataBrokerProfileQueries + repository.customGetLatestWeeklyPixel = nil + + sut.tryToFireWeeklyPixels() + + let weeklyReportScanningPixel = MockDataBrokerProtectionPixelsHandler.lastPixelsFired.first! + let scanCoverage = weeklyReportScanningPixel.params!["scan_coverage"]! + + XCTAssertEqual("50", scanCoverage) + } +} + +final class MockDataBrokerProtectionEventPixelsRepository: DataBrokerProtectionEventPixelsRepository { + + var wasMarkWeeklyPixelSentCalled = false + var customGetLatestWeeklyPixel: Date? + + func markWeeklyPixelSent() { + wasMarkWeeklyPixelSentCalled = true + } + + func getLatestWeeklyPixel() -> Date? { + return customGetLatestWeeklyPixel + } + + func clear() { + wasMarkWeeklyPixelSentCalled = false + customGetLatestWeeklyPixel = nil + } +} From 67c9d66e68e911cb80fcd368af55c5389180af5f Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez <diegoreymendez@users.noreply.github.com> Date: Mon, 18 Mar 2024 00:29:02 +0100 Subject: [PATCH 16/16] Improves VPN pixel info and adds tests (#2434) Task/Issue URL: https://app.asana.com/0/0/1206857816167860/f iOS: https://github.com/duckduckgo/iOS/pull/2604 BSK: https://github.com/duckduckgo/BrowserServicesKit/pull/732 ## Description 1. Makes `NetworkProtectionPixelEvent` implement `PixelKitEventV2` to make it easier for me to add automated tests. 2. Ensures all our error pixels contain both error and underlying error information. 3. Adds automated to validate that all pixels in `NetworkProtectionPixelEvent` send the right information. --- DuckDuckGo.xcodeproj/project.pbxproj | 8 +- .../xcshareddata/swiftpm/Package.resolved | 8 +- .../DuckDuckGo Privacy Browser.xcscheme | 3 + .../NetworkProtectionPixelEvent.swift | 210 +++++++---- .../EventMapping+NetworkProtectionError.swift | 8 +- .../NetworkProtectionTunnelController.swift | 2 +- .../MacPacketTunnelProvider.swift | 23 +- .../DataBrokerProtection/Package.swift | 2 +- .../NetworkProtectionMac/Package.swift | 2 +- .../PixelKit/PixelKit+Parameters.swift | 2 +- .../PixelFireExpectations.swift | 28 +- .../XCTestCase+PixelKit.swift | 42 +-- LocalPackages/SubscriptionUI/Package.swift | 2 +- .../NetworkProtectionPixelEventTests.swift | 345 ++++++++++++++++++ 14 files changed, 563 insertions(+), 122 deletions(-) create mode 100644 UnitTests/NetworkProtection/NetworkProtectionPixelEventTests.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index c985be5176..9f4d19a784 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -2171,6 +2171,8 @@ 7B00997F2B6508C200FE7C31 /* NetworkProtectionProxy in Frameworks */ = {isa = PBXBuildFile; productRef = 7B00997E2B6508C200FE7C31 /* NetworkProtectionProxy */; }; 7B0099822B65C6B300FE7C31 /* MacTransparentProxyProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B0099802B65C6B300FE7C31 /* MacTransparentProxyProvider.swift */; }; 7B0694982B6E980F00FA4DBA /* VPNProxyLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B0694972B6E980F00FA4DBA /* VPNProxyLauncher.swift */; }; + 7B09CBA92BA4BE8100CF245B /* NetworkProtectionPixelEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B09CBA72BA4BE7000CF245B /* NetworkProtectionPixelEventTests.swift */; }; + 7B09CBAA2BA4BE8200CF245B /* NetworkProtectionPixelEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B09CBA72BA4BE7000CF245B /* NetworkProtectionPixelEventTests.swift */; }; 7B1459542B7D437200047F2C /* BrowserWindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B0099782B65013800FE7C31 /* BrowserWindowManager.swift */; }; 7B1459552B7D438F00047F2C /* VPNProxyLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B0694972B6E980F00FA4DBA /* VPNProxyLauncher.swift */; }; 7B1459572B7D43E500047F2C /* NetworkProtectionProxy in Frameworks */ = {isa = PBXBuildFile; productRef = 7B1459562B7D43E500047F2C /* NetworkProtectionProxy */; }; @@ -3833,6 +3835,7 @@ 7B0099802B65C6B300FE7C31 /* MacTransparentProxyProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacTransparentProxyProvider.swift; sourceTree = "<group>"; }; 7B05829D2A812AC000AC3F7C /* NetworkProtectionOnboardingMenu.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkProtectionOnboardingMenu.swift; sourceTree = "<group>"; }; 7B0694972B6E980F00FA4DBA /* VPNProxyLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNProxyLauncher.swift; sourceTree = "<group>"; }; + 7B09CBA72BA4BE7000CF245B /* NetworkProtectionPixelEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionPixelEventTests.swift; sourceTree = "<group>"; }; 7B1E819B27C8874900FF0E60 /* ContentOverlayPopover.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentOverlayPopover.swift; sourceTree = "<group>"; }; 7B1E819C27C8874900FF0E60 /* ContentOverlay.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = ContentOverlay.storyboard; sourceTree = "<group>"; }; 7B1E819D27C8874900FF0E60 /* ContentOverlayViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentOverlayViewController.swift; sourceTree = "<group>"; }; @@ -6095,6 +6098,7 @@ 4BCF15E62ABB98A20083F6DF /* Resources */, 4BCF15E42ABB98990083F6DF /* NetworkProtectionRemoteMessageTests.swift */, 4BD57C032AC112DF00B580EE /* NetworkProtectionRemoteMessagingTests.swift */, + 7B09CBA72BA4BE7000CF245B /* NetworkProtectionPixelEventTests.swift */, ); path = NetworkProtection; sourceTree = "<group>"; @@ -10702,6 +10706,7 @@ 3706FE78293F661700E42796 /* HistoryCoordinatingMock.swift in Sources */, 3706FE79293F661700E42796 /* AppearancePreferencesTests.swift in Sources */, 3706FE7A293F661700E42796 /* FirePopoverViewModelTests.swift in Sources */, + 7B09CBAA2BA4BE8200CF245B /* NetworkProtectionPixelEventTests.swift in Sources */, 3706FE7B293F661700E42796 /* HistoryStoringMock.swift in Sources */, 562984702AC4610100AC20EB /* SyncPreferencesTests.swift in Sources */, 3706FE7C293F661700E42796 /* LocalBookmarkStoreTests.swift in Sources */, @@ -12509,6 +12514,7 @@ FD23FD2B28816606007F6985 /* AutoconsentMessageProtocolTests.swift in Sources */, 1D77921A28FDC79800BE0210 /* FaviconStoringMock.swift in Sources */, 1D1C36E629FB019C001FA40C /* HistoryTabExtensionTests.swift in Sources */, + 7B09CBA92BA4BE8100CF245B /* NetworkProtectionPixelEventTests.swift in Sources */, B6DA441E2616C84600DD1EC2 /* PixelStoreMock.swift in Sources */, 4B434690285ED7A100177407 /* BookmarksBarViewModelTests.swift in Sources */, B6BBF1702744CDE1004F850E /* CoreDataStoreTests.swift in Sources */, @@ -13749,7 +13755,7 @@ repositoryURL = "https://github.com/duckduckgo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 125.0.0; + version = 125.0.1; }; }; AA06B6B52672AF8100F541C5 /* XCRemoteSwiftPackageReference "Sparkle" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 24e42fd976..23b4cce189 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/BrowserServicesKit", "state" : { - "revision" : "ac2127a26f75b2aa293f6036bcdd2bc241d09819", - "version" : "125.0.0" + "revision" : "eced8f93c945ff2fa4ff92bdd619514d4eff7131", + "version" : "125.0.1" } }, { @@ -122,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "c8ed701b513cf5177118a175d85fbbbcd707ab41", - "version" : "1.3.0" + "revision" : "46989693916f56d1186bd59ac15124caef896560", + "version" : "1.3.1" } }, { diff --git a/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser.xcscheme b/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser.xcscheme index 58557db074..0224eeaec8 100644 --- a/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser.xcscheme +++ b/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo Privacy Browser.xcscheme @@ -120,6 +120,9 @@ <Test Identifier = "PixelStoreTests/testWhenValuesAreRemovedThenTheyAreNotInCache()"> </Test> + <Test + Identifier = "PreferencesSidebarModelTests/testWhenResetTabSelectionIfNeededCalledThenPreferencesTabIsSelected()"> + </Test> <Test Identifier = "StatisticsLoaderTests/testWhenRefreshRetentionAtbIsPerformedForNavigationThenAppRetentionAtbRequested()"> </Test> diff --git a/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/NetworkProtectionPixelEvent.swift b/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/NetworkProtectionPixelEvent.swift index f7e491577b..2f60171c17 100644 --- a/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/NetworkProtectionPixelEvent.swift +++ b/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/NetworkProtectionPixelEvent.swift @@ -22,22 +22,23 @@ import Foundation import PixelKit import NetworkProtection -enum NetworkProtectionPixelEvent: PixelKitEvent { +enum NetworkProtectionPixelEvent: PixelKitEventV2 { + static let vpnErrorDomain = "com.duckduckgo.vpn.errorDomain" case networkProtectionActiveUser case networkProtectionNewUser case networkProtectionControllerStartAttempt case networkProtectionControllerStartSuccess - case networkProtectionControllerStartFailure + case networkProtectionControllerStartFailure(_ error: Error) case networkProtectionTunnelStartAttempt case networkProtectionTunnelStartSuccess - case networkProtectionTunnelStartFailure + case networkProtectionTunnelStartFailure(_ error: Error) case networkProtectionTunnelUpdateAttempt case networkProtectionTunnelUpdateSuccess - case networkProtectionTunnelUpdateFailure + case networkProtectionTunnelUpdateFailure(_ error: Error) case networkProtectionEnableAttemptConnecting case networkProtectionEnableAttemptSuccess @@ -55,23 +56,23 @@ enum NetworkProtectionPixelEvent: PixelKitEvent { case networkProtectionTunnelConfigurationCouldNotGetPeerHostName case networkProtectionTunnelConfigurationCouldNotGetInterfaceAddressRange - case networkProtectionClientFailedToFetchServerList(error: Error?) + case networkProtectionClientFailedToFetchServerList(_ error: Error?) case networkProtectionClientFailedToParseServerListResponse case networkProtectionClientFailedToEncodeRegisterKeyRequest - case networkProtectionClientFailedToFetchRegisteredServers(error: Error?) + case networkProtectionClientFailedToFetchRegisteredServers(_ error: Error?) case networkProtectionClientFailedToParseRegisteredServersResponse case networkProtectionClientFailedToEncodeRedeemRequest case networkProtectionClientInvalidInviteCode - case networkProtectionClientFailedToRedeemInviteCode(error: Error?) - case networkProtectionClientFailedToParseRedeemResponse(error: Error) - case networkProtectionClientFailedToFetchLocations(error: Error?) - case networkProtectionClientFailedToParseLocationsResponse(error: Error?) + case networkProtectionClientFailedToRedeemInviteCode(_ error: Error?) + case networkProtectionClientFailedToParseRedeemResponse(_ error: Error) + case networkProtectionClientFailedToFetchLocations(_ error: Error?) + case networkProtectionClientFailedToParseLocationsResponse(_ error: Error?) case networkProtectionClientInvalidAuthToken case networkProtectionServerListStoreFailedToEncodeServerList case networkProtectionServerListStoreFailedToDecodeServerList - case networkProtectionServerListStoreFailedToWriteServerList(error: Error) - case networkProtectionServerListStoreFailedToReadServerList(error: Error) + case networkProtectionServerListStoreFailedToWriteServerList(_ error: Error) + case networkProtectionServerListStoreFailedToReadServerList(_ error: Error) case networkProtectionKeychainErrorFailedToCastKeychainValueToData(field: String) case networkProtectionKeychainReadError(field: String, status: Int32) @@ -82,14 +83,14 @@ enum NetworkProtectionPixelEvent: PixelKitEvent { case networkProtectionWireguardErrorCannotLocateTunnelFileDescriptor case networkProtectionWireguardErrorInvalidState(reason: String) case networkProtectionWireguardErrorFailedDNSResolution - case networkProtectionWireguardErrorCannotSetNetworkSettings(error: Error) + case networkProtectionWireguardErrorCannotSetNetworkSettings(_ error: Error) case networkProtectionWireguardErrorCannotStartWireguardBackend(code: Int32) case networkProtectionNoAuthTokenFoundError case networkProtectionRekeyAttempt case networkProtectionRekeyCompleted - case networkProtectionRekeyFailure + case networkProtectionRekeyFailure(_ error: Error) case networkProtectionSystemExtensionActivationFailure @@ -102,169 +103,169 @@ enum NetworkProtectionPixelEvent: PixelKitEvent { switch self { case .networkProtectionActiveUser: - return "m_mac_netp_daily_active" + return "netp_daily_active" case .networkProtectionNewUser: - return "m_mac_netp_daily_active_u" + return "netp_daily_active_u" case .networkProtectionControllerStartAttempt: - return "m_mac_netp_controller_start_attempt" + return "netp_controller_start_attempt" case .networkProtectionControllerStartSuccess: - return "m_mac_netp_controller_start_success" + return "netp_controller_start_success" case .networkProtectionControllerStartFailure: - return "m_mac_netp_controller_start_failure" + return "netp_controller_start_failure" case .networkProtectionTunnelStartAttempt: - return "m_mac_netp_tunnel_start_attempt" + return "netp_tunnel_start_attempt" case .networkProtectionTunnelStartSuccess: - return "m_mac_netp_tunnel_start_success" + return "netp_tunnel_start_success" case .networkProtectionTunnelStartFailure: - return "m_mac_netp_tunnel_start_failure" + return "netp_tunnel_start_failure" case .networkProtectionTunnelUpdateAttempt: - return "m_mac_netp_tunnel_update_attempt" + return "netp_tunnel_update_attempt" case .networkProtectionTunnelUpdateSuccess: - return "m_mac_netp_tunnel_update_success" + return "netp_tunnel_update_success" case .networkProtectionTunnelUpdateFailure: - return "m_mac_netp_tunnel_update_failure" + return "netp_tunnel_update_failure" case .networkProtectionEnableAttemptConnecting: - return "m_mac_netp_ev_enable_attempt" + return "netp_ev_enable_attempt" case .networkProtectionEnableAttemptSuccess: - return "m_mac_netp_ev_enable_attempt_success" + return "netp_ev_enable_attempt_success" case .networkProtectionEnableAttemptFailure: - return "m_mac_netp_ev_enable_attempt_failure" + return "netp_ev_enable_attempt_failure" case .networkProtectionTunnelFailureDetected: - return "m_mac_netp_ev_tunnel_failure" + return "netp_ev_tunnel_failure" case .networkProtectionTunnelFailureRecovered: - return "m_mac_netp_ev_tunnel_failure_recovered" + return "netp_ev_tunnel_failure_recovered" case .networkProtectionLatency(let quality): - return "m_mac_netp_ev_\(quality.rawValue)_latency" + return "netp_ev_\(quality.rawValue)_latency" case .networkProtectionLatencyError: - return "m_mac_netp_ev_latency_error" + return "netp_ev_latency_error" case .networkProtectionTunnelConfigurationNoServerRegistrationInfo: - return "m_mac_netp_tunnel_config_error_no_server_registration_info" + return "netp_tunnel_config_error_no_server_registration_info" case .networkProtectionTunnelConfigurationCouldNotSelectClosestServer: - return "m_mac_netp_tunnel_config_error_could_not_select_closest_server" + return "netp_tunnel_config_error_could_not_select_closest_server" case .networkProtectionTunnelConfigurationCouldNotGetPeerPublicKey: - return "m_mac_netp_tunnel_config_error_could_not_get_peer_public_key" + return "netp_tunnel_config_error_could_not_get_peer_public_key" case .networkProtectionTunnelConfigurationCouldNotGetPeerHostName: - return "m_mac_netp_tunnel_config_error_could_not_get_peer_host_name" + return "netp_tunnel_config_error_could_not_get_peer_host_name" case .networkProtectionTunnelConfigurationCouldNotGetInterfaceAddressRange: - return "m_mac_netp_tunnel_config_error_could_not_get_interface_address_range" + return "netp_tunnel_config_error_could_not_get_interface_address_range" case .networkProtectionClientFailedToFetchServerList: - return "m_mac_netp_backend_api_error_failed_to_fetch_server_list" + return "netp_backend_api_error_failed_to_fetch_server_list" case .networkProtectionClientFailedToParseServerListResponse: - return "m_mac_netp_backend_api_error_parsing_server_list_response_failed" + return "netp_backend_api_error_parsing_server_list_response_failed" case .networkProtectionClientFailedToEncodeRegisterKeyRequest: - return "m_mac_netp_backend_api_error_encoding_register_request_body_failed" + return "netp_backend_api_error_encoding_register_request_body_failed" case .networkProtectionClientFailedToFetchRegisteredServers: - return "m_mac_netp_backend_api_error_failed_to_fetch_registered_servers" + return "netp_backend_api_error_failed_to_fetch_registered_servers" case .networkProtectionClientFailedToParseRegisteredServersResponse: - return "m_mac_netp_backend_api_error_parsing_device_registration_response_failed" + return "netp_backend_api_error_parsing_device_registration_response_failed" case .networkProtectionClientFailedToEncodeRedeemRequest: - return "m_mac_netp_backend_api_error_encoding_redeem_request_body_failed" + return "netp_backend_api_error_encoding_redeem_request_body_failed" case .networkProtectionClientInvalidInviteCode: - return "m_mac_netp_backend_api_error_invalid_invite_code" + return "netp_backend_api_error_invalid_invite_code" case .networkProtectionClientFailedToRedeemInviteCode: - return "m_mac_netp_backend_api_error_failed_to_redeem_invite_code" + return "netp_backend_api_error_failed_to_redeem_invite_code" case .networkProtectionClientFailedToParseRedeemResponse: - return "m_mac_netp_backend_api_error_parsing_redeem_response_failed" + return "netp_backend_api_error_parsing_redeem_response_failed" case .networkProtectionClientFailedToFetchLocations: - return "m_mac_netp_backend_api_error_failed_to_fetch_location_list" + return "netp_backend_api_error_failed_to_fetch_location_list" case .networkProtectionClientFailedToParseLocationsResponse: - return "m_mac_netp_backend_api_error_parsing_location_list_response_failed" + return "netp_backend_api_error_parsing_location_list_response_failed" case .networkProtectionClientInvalidAuthToken: - return "m_mac_netp_backend_api_error_invalid_auth_token" + return "netp_backend_api_error_invalid_auth_token" case .networkProtectionServerListStoreFailedToEncodeServerList: - return "m_mac_netp_storage_error_failed_to_encode_server_list" + return "netp_storage_error_failed_to_encode_server_list" case .networkProtectionServerListStoreFailedToDecodeServerList: - return "m_mac_netp_storage_error_failed_to_decode_server_list" + return "netp_storage_error_failed_to_decode_server_list" case .networkProtectionServerListStoreFailedToWriteServerList: - return "m_mac_netp_storage_error_server_list_file_system_write_failed" + return "netp_storage_error_server_list_file_system_write_failed" case .networkProtectionServerListStoreFailedToReadServerList: - return "m_mac_netp_storage_error_server_list_file_system_read_failed" + return "netp_storage_error_server_list_file_system_read_failed" case .networkProtectionKeychainErrorFailedToCastKeychainValueToData: - return "m_mac_netp_keychain_error_failed_to_cast_keychain_value_to_data" + return "netp_keychain_error_failed_to_cast_keychain_value_to_data" case .networkProtectionKeychainReadError: - return "m_mac_netp_keychain_error_read_failed" + return "netp_keychain_error_read_failed" case .networkProtectionKeychainWriteError: - return "m_mac_netp_keychain_error_write_failed" + return "netp_keychain_error_write_failed" case .networkProtectionKeychainUpdateError: - return "m_mac_netp_keychain_error_update_failed" + return "netp_keychain_error_update_failed" case .networkProtectionKeychainDeleteError: - return "m_mac_netp_keychain_error_delete_failed" + return "netp_keychain_error_delete_failed" case .networkProtectionWireguardErrorCannotLocateTunnelFileDescriptor: - return "m_mac_netp_wireguard_error_cannot_locate_tunnel_file_descriptor" + return "netp_wireguard_error_cannot_locate_tunnel_file_descriptor" case .networkProtectionWireguardErrorInvalidState: - return "m_mac_netp_wireguard_error_invalid_state" + return "netp_wireguard_error_invalid_state" case .networkProtectionWireguardErrorFailedDNSResolution: - return "m_mac_netp_wireguard_error_failed_dns_resolution" + return "netp_wireguard_error_failed_dns_resolution" case .networkProtectionWireguardErrorCannotSetNetworkSettings: - return "m_mac_netp_wireguard_error_cannot_set_network_settings" + return "netp_wireguard_error_cannot_set_network_settings" case .networkProtectionWireguardErrorCannotStartWireguardBackend: - return "m_mac_netp_wireguard_error_cannot_start_wireguard_backend" + return "netp_wireguard_error_cannot_start_wireguard_backend" case .networkProtectionNoAuthTokenFoundError: - return "m_mac_netp_no_auth_token_found_error" + return "netp_no_auth_token_found_error" case .networkProtectionRekeyAttempt: - return "m_mac_netp_rekey_attempt" + return "netp_rekey_attempt" case .networkProtectionRekeyCompleted: - return "m_mac_netp_rekey_completed" + return "netp_rekey_completed" case .networkProtectionRekeyFailure: - return "m_mac_netp_rekey_failure" + return "netp_rekey_failure" case .networkProtectionSystemExtensionActivationFailure: - return "m_mac_netp_system_extension_activation_failure" + return "netp_system_extension_activation_failure" case .networkProtectionUnhandledError: - return "m_mac_netp_unhandled_error" + return "netp_unhandled_error" } } @@ -308,13 +309,13 @@ enum NetworkProtectionPixelEvent: PixelKitEvent { case .networkProtectionClientFailedToFetchRegisteredServers(let error): return error?.pixelParameters - case .networkProtectionClientFailedToRedeemInviteCode(error: let error): + case .networkProtectionClientFailedToRedeemInviteCode(let error): return error?.pixelParameters - case .networkProtectionClientFailedToFetchLocations(error: let error): + case .networkProtectionClientFailedToFetchLocations(let error): return error?.pixelParameters - case .networkProtectionClientFailedToParseLocationsResponse(error: let error): + case .networkProtectionClientFailedToParseLocationsResponse(let error): return error?.pixelParameters case .networkProtectionUnhandledError(let function, let line, let error): @@ -323,7 +324,7 @@ enum NetworkProtectionPixelEvent: PixelKitEvent { parameters[PixelKit.Parameters.line] = String(line) return parameters - case .networkProtectionWireguardErrorCannotSetNetworkSettings(error: let error): + case .networkProtectionWireguardErrorCannotSetNetworkSettings(let error): return error.pixelParameters case .networkProtectionWireguardErrorCannotStartWireguardBackend(code: let code): @@ -379,6 +380,69 @@ enum NetworkProtectionPixelEvent: PixelKitEvent { return nil } } + + var error: (any Error)? { + switch self { + case .networkProtectionActiveUser, + .networkProtectionNewUser, + .networkProtectionControllerStartAttempt, + .networkProtectionControllerStartSuccess, + .networkProtectionTunnelStartAttempt, + .networkProtectionTunnelStartSuccess, + .networkProtectionTunnelUpdateAttempt, + .networkProtectionTunnelUpdateSuccess, + .networkProtectionEnableAttemptConnecting, + .networkProtectionEnableAttemptSuccess, + .networkProtectionEnableAttemptFailure, + .networkProtectionTunnelFailureDetected, + .networkProtectionTunnelFailureRecovered, + .networkProtectionLatencyError, + .networkProtectionLatency, + .networkProtectionTunnelConfigurationNoServerRegistrationInfo, + .networkProtectionTunnelConfigurationCouldNotGetPeerPublicKey, + .networkProtectionTunnelConfigurationCouldNotGetPeerHostName, + .networkProtectionTunnelConfigurationCouldNotGetInterfaceAddressRange, + .networkProtectionClientFailedToParseServerListResponse, + .networkProtectionClientFailedToEncodeRegisterKeyRequest, + .networkProtectionClientFailedToParseRegisteredServersResponse, + .networkProtectionTunnelConfigurationCouldNotSelectClosestServer, + .networkProtectionClientFailedToEncodeRedeemRequest, + .networkProtectionClientInvalidInviteCode, + .networkProtectionClientInvalidAuthToken, + .networkProtectionServerListStoreFailedToEncodeServerList, + .networkProtectionServerListStoreFailedToDecodeServerList, + .networkProtectionKeychainErrorFailedToCastKeychainValueToData, + .networkProtectionKeychainReadError, + .networkProtectionKeychainWriteError, + .networkProtectionKeychainUpdateError, + .networkProtectionKeychainDeleteError, + .networkProtectionWireguardErrorCannotLocateTunnelFileDescriptor, + .networkProtectionWireguardErrorInvalidState, + .networkProtectionWireguardErrorFailedDNSResolution, + .networkProtectionWireguardErrorCannotStartWireguardBackend, + .networkProtectionNoAuthTokenFoundError, + .networkProtectionRekeyAttempt, + .networkProtectionRekeyCompleted, + .networkProtectionSystemExtensionActivationFailure: + return nil + case .networkProtectionClientFailedToRedeemInviteCode(let error), + .networkProtectionClientFailedToFetchLocations(let error), + .networkProtectionClientFailedToParseLocationsResponse(let error), + .networkProtectionClientFailedToFetchServerList(let error), + .networkProtectionClientFailedToFetchRegisteredServers(let error): + return error + case .networkProtectionControllerStartFailure(let error), + .networkProtectionTunnelStartFailure(let error), + .networkProtectionTunnelUpdateFailure(let error), + .networkProtectionClientFailedToParseRedeemResponse(let error), + .networkProtectionServerListStoreFailedToWriteServerList(let error), + .networkProtectionServerListStoreFailedToReadServerList(let error), + .networkProtectionWireguardErrorCannotSetNetworkSettings(let error), + .networkProtectionRekeyFailure(let error), + .networkProtectionUnhandledError(_, _, let error): + return error + } + } } #endif diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/EventMapping+NetworkProtectionError.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/EventMapping+NetworkProtectionError.swift index b363c3d843..3a42455d0f 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/EventMapping+NetworkProtectionError.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/EventMapping+NetworkProtectionError.swift @@ -36,10 +36,10 @@ extension EventMapping where Event == NetworkProtectionError { domainEvent = .networkProtectionClientInvalidInviteCode frequency = .standard case .failedToRedeemInviteCode(let error): - domainEvent = .networkProtectionClientFailedToRedeemInviteCode(error: error) + domainEvent = .networkProtectionClientFailedToRedeemInviteCode(error) frequency = .standard case .failedToParseRedeemResponse(let error): - domainEvent = .networkProtectionClientFailedToParseRedeemResponse(error: error) + domainEvent = .networkProtectionClientFailedToParseRedeemResponse(error) frequency = .standard case .invalidAuthToken: domainEvent = .networkProtectionClientInvalidAuthToken @@ -63,10 +63,10 @@ extension EventMapping where Event == NetworkProtectionError { domainEvent = .networkProtectionNoAuthTokenFoundError frequency = .standard case .failedToFetchLocationList(let error): - domainEvent = .networkProtectionClientFailedToFetchLocations(error: error) + domainEvent = .networkProtectionClientFailedToFetchLocations(error) frequency = .dailyAndContinuous case .failedToParseLocationListResponse(let error): - domainEvent = .networkProtectionClientFailedToParseLocationsResponse(error: error) + domainEvent = .networkProtectionClientFailedToParseLocationsResponse(error) frequency = .dailyAndContinuous case .noServerRegistrationInfo, .couldNotSelectClosestServer, diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionTunnelController.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionTunnelController.swift index 0ea6d48740..4132457ca2 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionTunnelController.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionTunnelController.swift @@ -519,7 +519,7 @@ final class NetworkProtectionTunnelController: TunnelController, TunnelSessionPr } } catch { PixelKit.fire( - NetworkProtectionPixelEvent.networkProtectionControllerStartFailure, frequency: .dailyAndContinuous, withError: error, includeAppVersionParameter: true + NetworkProtectionPixelEvent.networkProtectionControllerStartFailure(error), frequency: .dailyAndContinuous, includeAppVersionParameter: true ) await stop() diff --git a/DuckDuckGo/NetworkProtection/NetworkExtensionTargets/NetworkExtensionTargets/MacPacketTunnelProvider.swift b/DuckDuckGo/NetworkProtection/NetworkExtensionTargets/NetworkExtensionTargets/MacPacketTunnelProvider.swift index d7d6de4627..da8d81fc24 100644 --- a/DuckDuckGo/NetworkProtection/NetworkExtensionTargets/NetworkExtensionTargets/MacPacketTunnelProvider.swift +++ b/DuckDuckGo/NetworkProtection/NetworkExtensionTargets/NetworkExtensionTargets/MacPacketTunnelProvider.swift @@ -59,13 +59,13 @@ final class MacPacketTunnelProvider: PacketTunnelProvider { case .couldNotGetInterfaceAddressRange: domainEvent = .networkProtectionTunnelConfigurationCouldNotGetInterfaceAddressRange case .failedToFetchServerList(let eventError): - domainEvent = .networkProtectionClientFailedToFetchServerList(error: eventError) + domainEvent = .networkProtectionClientFailedToFetchServerList(eventError) case .failedToParseServerListResponse: domainEvent = .networkProtectionClientFailedToParseServerListResponse case .failedToEncodeRegisterKeyRequest: domainEvent = .networkProtectionClientFailedToEncodeRegisterKeyRequest case .failedToFetchRegisteredServers(let eventError): - domainEvent = .networkProtectionClientFailedToFetchRegisteredServers(error: eventError) + domainEvent = .networkProtectionClientFailedToFetchRegisteredServers(eventError) case .failedToParseRegisteredServersResponse: domainEvent = .networkProtectionClientFailedToParseRegisteredServersResponse case .failedToEncodeRedeemRequest: @@ -73,9 +73,9 @@ final class MacPacketTunnelProvider: PacketTunnelProvider { case .invalidInviteCode: domainEvent = .networkProtectionClientInvalidInviteCode case .failedToRedeemInviteCode(let error): - domainEvent = .networkProtectionClientFailedToRedeemInviteCode(error: error) + domainEvent = .networkProtectionClientFailedToRedeemInviteCode(error) case .failedToParseRedeemResponse(let error): - domainEvent = .networkProtectionClientFailedToParseRedeemResponse(error: error) + domainEvent = .networkProtectionClientFailedToParseRedeemResponse(error) case .invalidAuthToken: domainEvent = .networkProtectionClientInvalidAuthToken case .serverListInconsistency: @@ -85,13 +85,13 @@ final class MacPacketTunnelProvider: PacketTunnelProvider { case .failedToDecodeServerList: domainEvent = .networkProtectionServerListStoreFailedToDecodeServerList case .failedToWriteServerList(let eventError): - domainEvent = .networkProtectionServerListStoreFailedToWriteServerList(error: eventError) + domainEvent = .networkProtectionServerListStoreFailedToWriteServerList(eventError) case .noServerListFound: return case .couldNotCreateServerListDirectory: return case .failedToReadServerList(let eventError): - domainEvent = .networkProtectionServerListStoreFailedToReadServerList(error: eventError) + domainEvent = .networkProtectionServerListStoreFailedToReadServerList(eventError) case .failedToCastKeychainValueToData(let field): domainEvent = .networkProtectionKeychainErrorFailedToCastKeychainValueToData(field: field) case .keychainReadError(let field, let status): @@ -109,7 +109,7 @@ final class MacPacketTunnelProvider: PacketTunnelProvider { case .wireGuardDnsResolution: domainEvent = .networkProtectionWireguardErrorFailedDNSResolution case .wireGuardSetNetworkSettings(let error): - domainEvent = .networkProtectionWireguardErrorCannotSetNetworkSettings(error: error) + domainEvent = .networkProtectionWireguardErrorCannotSetNetworkSettings(error) case .startWireGuardBackend(let code): domainEvent = .networkProtectionWireguardErrorCannotStartWireguardBackend(code: code) case .noAuthTokenFound: @@ -206,9 +206,8 @@ final class MacPacketTunnelProvider: PacketTunnelProvider { includeAppVersionParameter: true) case .failure(let error): PixelKit.fire( - NetworkProtectionPixelEvent.networkProtectionRekeyFailure, + NetworkProtectionPixelEvent.networkProtectionRekeyFailure(error), frequency: .dailyAndContinuous, - withError: error, includeAppVersionParameter: true) case .success: PixelKit.fire( @@ -225,9 +224,8 @@ final class MacPacketTunnelProvider: PacketTunnelProvider { includeAppVersionParameter: true) case .failure(let error): PixelKit.fire( - NetworkProtectionPixelEvent.networkProtectionTunnelStartFailure, + NetworkProtectionPixelEvent.networkProtectionTunnelStartFailure(error), frequency: .dailyAndContinuous, - withError: error, includeAppVersionParameter: true) case .success: PixelKit.fire( @@ -244,9 +242,8 @@ final class MacPacketTunnelProvider: PacketTunnelProvider { includeAppVersionParameter: true) case .failure(let error): PixelKit.fire( - NetworkProtectionPixelEvent.networkProtectionTunnelUpdateFailure, + NetworkProtectionPixelEvent.networkProtectionTunnelUpdateFailure(error), frequency: .dailyAndContinuous, - withError: error, includeAppVersionParameter: true) case .success: PixelKit.fire( diff --git a/LocalPackages/DataBrokerProtection/Package.swift b/LocalPackages/DataBrokerProtection/Package.swift index d428da60b2..20e8b1f4b2 100644 --- a/LocalPackages/DataBrokerProtection/Package.swift +++ b/LocalPackages/DataBrokerProtection/Package.swift @@ -29,7 +29,7 @@ let package = Package( targets: ["DataBrokerProtection"]) ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "125.0.0"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "125.0.1"), .package(path: "../PixelKit"), .package(path: "../SwiftUIExtensions"), .package(path: "../XPCHelper"), diff --git a/LocalPackages/NetworkProtectionMac/Package.swift b/LocalPackages/NetworkProtectionMac/Package.swift index 39f13d9fe0..baddd89369 100644 --- a/LocalPackages/NetworkProtectionMac/Package.swift +++ b/LocalPackages/NetworkProtectionMac/Package.swift @@ -31,7 +31,7 @@ let package = Package( .library(name: "NetworkProtectionUI", targets: ["NetworkProtectionUI"]) ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "125.0.0"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "125.0.1"), .package(path: "../XPCHelper"), .package(path: "../SwiftUIExtensions"), .package(path: "../LoginItems"), diff --git a/LocalPackages/PixelKit/Sources/PixelKit/PixelKit+Parameters.swift b/LocalPackages/PixelKit/Sources/PixelKit/PixelKit+Parameters.swift index ea08352d29..e368d38db5 100644 --- a/LocalPackages/PixelKit/Sources/PixelKit/PixelKit+Parameters.swift +++ b/LocalPackages/PixelKit/Sources/PixelKit/PixelKit+Parameters.swift @@ -20,7 +20,7 @@ import Foundation public extension PixelKit { - enum Parameters { + enum Parameters: Hashable { public static let duration = "duration" public static let test = "test" public static let appVersion = "appVersion" diff --git a/LocalPackages/PixelKit/Sources/PixelKitTestingUtilities/PixelFireExpectations.swift b/LocalPackages/PixelKit/Sources/PixelKitTestingUtilities/PixelFireExpectations.swift index 067eee091e..db56d6664b 100644 --- a/LocalPackages/PixelKit/Sources/PixelKitTestingUtilities/PixelFireExpectations.swift +++ b/LocalPackages/PixelKit/Sources/PixelKitTestingUtilities/PixelFireExpectations.swift @@ -17,6 +17,7 @@ // import Foundation +import PixelKit /// Structure containing information about a pixel fire event. /// @@ -27,10 +28,35 @@ public struct PixelFireExpectations { let pixelName: String var error: Error? var underlyingError: Error? + var customFields: [String: String]? - public init(pixelName: String, error: Error? = nil, underlyingError: Error? = nil) { + /// Convenience initializer for cleaner semantics + /// + public static func expect(pixelName: String, error: Error? = nil, underlyingError: Error? = nil, customFields: [String: String]? = nil) -> PixelFireExpectations { + + .init(pixelName: pixelName, error: error, underlyingError: underlyingError, customFields: customFields) + } + + public init(pixelName: String, error: Error? = nil, underlyingError: Error? = nil, customFields: [String: String]? = nil) { self.pixelName = pixelName self.error = error self.underlyingError = underlyingError + self.customFields = customFields + } + + public var parameters: [String: String] { + var parameters = customFields ?? [String: String]() + + if let nsError = error as? NSError { + parameters[PixelKit.Parameters.errorCode] = String(nsError.code) + parameters[PixelKit.Parameters.errorDomain] = nsError.domain + } + + if let nsUnderlyingError = underlyingError as? NSError { + parameters[PixelKit.Parameters.underlyingErrorCode] = String(nsUnderlyingError.code) + parameters[PixelKit.Parameters.underlyingErrorDomain] = nsUnderlyingError.domain + } + + return parameters } } diff --git a/LocalPackages/PixelKit/Sources/PixelKitTestingUtilities/XCTestCase+PixelKit.swift b/LocalPackages/PixelKit/Sources/PixelKitTestingUtilities/XCTestCase+PixelKit.swift index 325b7414b3..6d742bd532 100644 --- a/LocalPackages/PixelKit/Sources/PixelKitTestingUtilities/XCTestCase+PixelKit.swift +++ b/LocalPackages/PixelKit/Sources/PixelKitTestingUtilities/XCTestCase+PixelKit.swift @@ -66,7 +66,13 @@ public extension XCTestCase { #endif } - func expectedParameters(for event: PixelKitEventV2) -> [String: String] { + /// These parameters are known to be expected just based on the event definition. + /// + /// They're not a complete list of parameters for the event, as the fire call may contain extra information + /// that results in additional parameters. Ideally we want most (if not all) that information to eventually + /// make part of the pixel definition. + /// + func knownExpectedParameters(for event: PixelKitEventV2) -> [String: String] { var expectedParameters = [String: String]() if let error = event.error { @@ -74,10 +80,9 @@ public extension XCTestCase { expectedParameters[PixelKit.Parameters.errorCode] = "\(nsError.code)" expectedParameters[PixelKit.Parameters.errorDomain] = nsError.domain - if let underlyingError = (error as? PixelKitEventErrorDetails)?.underlyingError { - let underlyingNSError = underlyingError as NSError - expectedParameters[PixelKit.Parameters.underlyingErrorCode] = "\(underlyingNSError.code)" - expectedParameters[PixelKit.Parameters.underlyingErrorDomain] = underlyingNSError.domain + if let underlyingError = nsError.userInfo[NSUnderlyingErrorKey] as? NSError { + expectedParameters[PixelKit.Parameters.underlyingErrorCode] = "\(underlyingError.code)" + expectedParameters[PixelKit.Parameters.underlyingErrorDomain] = underlyingError.domain } } @@ -92,14 +97,18 @@ public extension XCTestCase { // MARK: - Pixel Firing Expectations + func fire(_ event: PixelKitEventV2, and expectations: PixelFireExpectations, file: StaticString, line: UInt) { + verifyThat(event, meets: expectations, file: file, line: line) + } + /// Provides some snapshot of a fired pixel so that external libraries can validate all the expected info is included. /// /// This method also checks that there is internal consistency in the expected fields. /// func verifyThat(_ event: PixelKitEventV2, meets expectations: PixelFireExpectations, file: StaticString, line: UInt) { - let expectedPixelName = Self.pixelPlatformPrefix + event.name - let expectedParameters = expectedParameters(for: event) + let expectedPixelName = event.name.hasPrefix(Self.pixelPlatformPrefix) ? event.name : Self.pixelPlatformPrefix + event.name + let knownExpectedParameters = knownExpectedParameters(for: event) let callbackExecutedExpectation = expectation(description: "The PixelKit callback has been executed") PixelKit.setUp(dryRun: false, @@ -115,23 +124,14 @@ public extension XCTestCase { // Internal validations XCTAssertEqual(firedPixelName, expectedPixelName, file: file, line: line) - XCTAssertEqual(firedParameters, expectedParameters, file: file, line: line) - // Expectations + XCTAssertTrue(knownExpectedParameters.allSatisfy { (key, value) in + firedParameters[key] == value + }) + // Expectations XCTAssertEqual(firedPixelName, expectations.pixelName) - - if let error = expectations.error { - let nsError = error as NSError - XCTAssertEqual(firedParameters[PixelKit.Parameters.errorCode], String(nsError.code), file: file, line: line) - XCTAssertEqual(firedParameters[PixelKit.Parameters.errorDomain], nsError.domain, file: file, line: line) - } - - if let underlyingError = expectations.underlyingError { - let nsError = underlyingError as NSError - XCTAssertEqual(firedParameters[PixelKit.Parameters.underlyingErrorCode], String(nsError.code), file: file, line: line) - XCTAssertEqual(firedParameters[PixelKit.Parameters.underlyingErrorDomain], nsError.domain, file: file, line: line) - } + XCTAssertEqual(firedParameters, expectations.parameters) completion(true, nil) } diff --git a/LocalPackages/SubscriptionUI/Package.swift b/LocalPackages/SubscriptionUI/Package.swift index 6ffb49191b..ef458f05c7 100644 --- a/LocalPackages/SubscriptionUI/Package.swift +++ b/LocalPackages/SubscriptionUI/Package.swift @@ -12,7 +12,7 @@ let package = Package( targets: ["SubscriptionUI"]), ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "125.0.0"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "125.0.1"), .package(path: "../SwiftUIExtensions") ], targets: [ diff --git a/UnitTests/NetworkProtection/NetworkProtectionPixelEventTests.swift b/UnitTests/NetworkProtection/NetworkProtectionPixelEventTests.swift new file mode 100644 index 0000000000..01a2cc72b1 --- /dev/null +++ b/UnitTests/NetworkProtection/NetworkProtectionPixelEventTests.swift @@ -0,0 +1,345 @@ +// +// NetworkProtectionPixelEventTests.swift +// +// 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. +// + +#if NETWORK_PROTECTION + +import NetworkProtection +import PixelKit +import PixelKitTestingUtilities +import XCTest +@testable import DuckDuckGo_Privacy_Browser + +final class NetworkProtectionPixelEventTests: XCTestCase { + + private enum TestError: CustomNSError { + case testError + case underlyingError + + /// The domain of the error. + static var errorDomain: String { + "testDomain" + } + + /// The error code within the given domain. + var errorCode: Int { + switch self { + case .testError: return 1 + case .underlyingError: return 2 + } + } + + /// The user-info dictionary. + var errorUserInfo: [String: Any] { + switch self { + case .testError: + return [NSUnderlyingErrorKey: TestError.underlyingError] + case .underlyingError: + return [:] + } + } + } + + // MARK: - Test Firing Pixels + + /// This test verifies validates expectations when firing `NetworkProtectionPixelEvent`. + /// + /// This test verifies a few different things: + /// - That the pixel name is not changed by mistake. + /// - That when the pixel is fired its name and parameters are exactly what's expected. + /// + func testVPNPixelFireExpectations() { + fire(NetworkProtectionPixelEvent.networkProtectionActiveUser, + and: .expect(pixelName: "m_mac_netp_daily_active"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionNewUser, + and: .expect(pixelName: "m_mac_netp_daily_active_u"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionControllerStartAttempt, + and: .expect(pixelName: "m_mac_netp_controller_start_attempt"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionControllerStartFailure(TestError.testError), + and: .expect(pixelName: "m_mac_netp_controller_start_failure", + error: TestError.testError, + underlyingError: TestError.underlyingError), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionControllerStartSuccess, + and: .expect(pixelName: "m_mac_netp_controller_start_success"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionTunnelStartAttempt, + and: .expect(pixelName: "m_mac_netp_tunnel_start_attempt"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionTunnelStartFailure(TestError.testError), + and: .expect(pixelName: "m_mac_netp_tunnel_start_failure", + error: TestError.testError, + underlyingError: TestError.underlyingError), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionTunnelStartSuccess, + and: .expect(pixelName: "m_mac_netp_tunnel_start_success"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionTunnelUpdateAttempt, + and: .expect(pixelName: "m_mac_netp_tunnel_update_attempt"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionTunnelUpdateFailure(TestError.testError), + and: .expect(pixelName: "m_mac_netp_tunnel_update_failure", + error: TestError.testError, + underlyingError: TestError.underlyingError), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionTunnelUpdateSuccess, + and: .expect(pixelName: "m_mac_netp_tunnel_update_success"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionEnableAttemptConnecting, + and: .expect(pixelName: "m_mac_netp_ev_enable_attempt"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionEnableAttemptSuccess, + and: .expect(pixelName: "m_mac_netp_ev_enable_attempt_success"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionEnableAttemptFailure, + and: .expect(pixelName: "m_mac_netp_ev_enable_attempt_failure"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionTunnelFailureDetected, + and: .expect(pixelName: "m_mac_netp_ev_tunnel_failure"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionTunnelFailureRecovered, + and: .expect(pixelName: "m_mac_netp_ev_tunnel_failure_recovered"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionLatency(quality: .excellent), + and: .expect(pixelName: "m_mac_netp_ev_\(NetworkProtectionLatencyMonitor.ConnectionQuality.excellent.rawValue)_latency"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionLatencyError, + and: .expect(pixelName: "m_mac_netp_ev_latency_error"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionTunnelConfigurationNoServerRegistrationInfo, + and: .expect(pixelName: "m_mac_netp_tunnel_config_error_no_server_registration_info"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionTunnelConfigurationCouldNotSelectClosestServer, + and: .expect(pixelName: "m_mac_netp_tunnel_config_error_could_not_select_closest_server"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionTunnelConfigurationCouldNotGetPeerPublicKey, + and: .expect(pixelName: "m_mac_netp_tunnel_config_error_could_not_get_peer_public_key"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionTunnelConfigurationCouldNotGetPeerHostName, + and: .expect(pixelName: "m_mac_netp_tunnel_config_error_could_not_get_peer_host_name"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionTunnelConfigurationCouldNotGetInterfaceAddressRange, + and: .expect(pixelName: "m_mac_netp_tunnel_config_error_could_not_get_interface_address_range"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionClientFailedToFetchServerList(TestError.testError), + and: .expect(pixelName: "m_mac_netp_backend_api_error_failed_to_fetch_server_list", + error: TestError.testError, + underlyingError: TestError.underlyingError), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionClientFailedToParseServerListResponse, + and: .expect(pixelName: "m_mac_netp_backend_api_error_parsing_server_list_response_failed"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionClientFailedToEncodeRegisterKeyRequest, + and: .expect(pixelName: "m_mac_netp_backend_api_error_encoding_register_request_body_failed"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionClientFailedToFetchRegisteredServers(TestError.testError), + and: .expect(pixelName: "m_mac_netp_backend_api_error_failed_to_fetch_registered_servers", + error: TestError.testError, + underlyingError: TestError.underlyingError), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionClientFailedToParseRegisteredServersResponse, + and: .expect(pixelName: "m_mac_netp_backend_api_error_parsing_device_registration_response_failed"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionClientFailedToEncodeRedeemRequest, + and: .expect(pixelName: "m_mac_netp_backend_api_error_encoding_redeem_request_body_failed"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionClientInvalidInviteCode, + and: .expect(pixelName: "m_mac_netp_backend_api_error_invalid_invite_code"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionClientFailedToRedeemInviteCode(TestError.testError), + and: .expect(pixelName: "m_mac_netp_backend_api_error_failed_to_redeem_invite_code", + error: TestError.testError, + underlyingError: TestError.underlyingError), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionClientFailedToParseRedeemResponse(TestError.testError), + and: .expect(pixelName: "m_mac_netp_backend_api_error_parsing_redeem_response_failed", + error: TestError.testError, + underlyingError: TestError.underlyingError), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionClientFailedToFetchLocations(TestError.testError), + and: .expect(pixelName: "m_mac_netp_backend_api_error_failed_to_fetch_location_list", + error: TestError.testError, + underlyingError: TestError.underlyingError), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionClientFailedToParseLocationsResponse(TestError.testError), + and: .expect(pixelName: "m_mac_netp_backend_api_error_parsing_location_list_response_failed", + error: TestError.testError, + underlyingError: TestError.underlyingError), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionClientInvalidAuthToken, + and: .expect(pixelName: "m_mac_netp_backend_api_error_invalid_auth_token"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionServerListStoreFailedToEncodeServerList, + and: .expect(pixelName: "m_mac_netp_storage_error_failed_to_encode_server_list"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionServerListStoreFailedToDecodeServerList, + and: .expect(pixelName: "m_mac_netp_storage_error_failed_to_decode_server_list"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionServerListStoreFailedToWriteServerList(TestError.testError), + and: .expect(pixelName: "m_mac_netp_storage_error_server_list_file_system_write_failed", + error: TestError.testError, + underlyingError: TestError.underlyingError), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionServerListStoreFailedToReadServerList(TestError.testError), + and: .expect(pixelName: "m_mac_netp_storage_error_server_list_file_system_read_failed", + error: TestError.testError, + underlyingError: TestError.underlyingError), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionKeychainErrorFailedToCastKeychainValueToData(field: "field"), + and: .expect(pixelName: "m_mac_netp_keychain_error_failed_to_cast_keychain_value_to_data", + customFields: [ + PixelKit.Parameters.keychainFieldName: "field", + ]), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionKeychainReadError(field: "field", status: 1), + and: .expect(pixelName: "m_mac_netp_keychain_error_read_failed", + customFields: [ + PixelKit.Parameters.keychainFieldName: "field", + PixelKit.Parameters.errorCode: "1", + ]), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionKeychainWriteError(field: "field", status: 1), + and: .expect(pixelName: "m_mac_netp_keychain_error_write_failed", + customFields: [ + PixelKit.Parameters.keychainFieldName: "field", + PixelKit.Parameters.errorCode: "1", + ]), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionKeychainUpdateError(field: "field", status: 1), + and: .expect(pixelName: "m_mac_netp_keychain_error_update_failed", + customFields: [ + PixelKit.Parameters.keychainFieldName: "field", + PixelKit.Parameters.errorCode: "1", + ]), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionKeychainDeleteError(status: 1), + and: .expect(pixelName: "m_mac_netp_keychain_error_delete_failed", + customFields: [ + PixelKit.Parameters.errorCode: "1" + ]), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionWireguardErrorCannotLocateTunnelFileDescriptor, + and: .expect(pixelName: "m_mac_netp_wireguard_error_cannot_locate_tunnel_file_descriptor"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionWireguardErrorInvalidState(reason: "reason"), + and: .expect(pixelName: "m_mac_netp_wireguard_error_invalid_state", + customFields: [ + PixelKit.Parameters.reason: "reason" + ]), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionWireguardErrorFailedDNSResolution, + and: .expect(pixelName: "m_mac_netp_wireguard_error_failed_dns_resolution"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionWireguardErrorCannotSetNetworkSettings(TestError.testError), + and: .expect(pixelName: "m_mac_netp_wireguard_error_cannot_set_network_settings", + error: TestError.testError, + underlyingError: TestError.underlyingError), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionWireguardErrorCannotStartWireguardBackend(code: 1), + and: .expect(pixelName: "m_mac_netp_wireguard_error_cannot_start_wireguard_backend", + customFields: [ + PixelKit.Parameters.errorCode: "1" + ]), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionNoAuthTokenFoundError, + and: .expect(pixelName: "m_mac_netp_no_auth_token_found_error"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionRekeyAttempt, + and: .expect(pixelName: "m_mac_netp_rekey_attempt"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionRekeyCompleted, + and: .expect(pixelName: "m_mac_netp_rekey_completed"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionRekeyFailure(TestError.testError), + and: .expect(pixelName: "m_mac_netp_rekey_failure", + error: TestError.testError, + underlyingError: TestError.underlyingError), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionSystemExtensionActivationFailure, + and: .expect(pixelName: "m_mac_netp_system_extension_activation_failure"), + file: #filePath, + line: #line) + fire(NetworkProtectionPixelEvent.networkProtectionUnhandledError(function: "function", line: 1, error: TestError.testError), + and: .expect(pixelName: "m_mac_netp_unhandled_error", + error: TestError.testError, + underlyingError: TestError.underlyingError, + customFields: [ + PixelKit.Parameters.function: "function", + PixelKit.Parameters.line: "1", + ]), + file: #filePath, + line: #line) + } +} + +#endif