diff --git a/.buildkite/commands/run-ui-tests.sh b/.buildkite/commands/run-ui-tests.sh index 4f7876fb6aa..987fba52f81 100755 --- a/.buildkite/commands/run-ui-tests.sh +++ b/.buildkite/commands/run-ui-tests.sh @@ -2,9 +2,8 @@ TEST_NAME=$1 DEVICE=$2 -IOS_VERSION=$3 -echo "Running $TEST_NAME on $DEVICE for iOS $IOS_VERSION" +echo "Running $TEST_NAME on $DEVICE" # Run this at the start to fail early if value not available echo '--- :test-analytics: Configuring Test Analytics' @@ -36,7 +35,7 @@ echo "--- 🧪 Testing" xcrun simctl list >> /dev/null rake mocks & set +e -bundle exec fastlane test_without_building name:"$TEST_NAME" device:"$DEVICE" ios_version:"$IOS_VERSION" +bundle exec fastlane test_without_building name:"$TEST_NAME" device:"$DEVICE" TESTS_EXIT_STATUS=$? set -e diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 6db7294fe0d..2f03fb20191 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -68,7 +68,7 @@ steps: # UI Tests ################# - label: "🔬 UI Tests (iPhone)" - command: .buildkite/commands/run-ui-tests.sh UITests 'iPhone 11' 15.0 + command: .buildkite/commands/run-ui-tests.sh UITests 'iPhone 11' depends_on: "build" env: *common_env plugins: *common_plugins @@ -79,7 +79,7 @@ steps: context: "UI Tests (iPhone)" - label: "🔬 UI Tests (iPad)" - command: .buildkite/commands/run-ui-tests.sh UITests "iPad Air (4th generation)" 15.0 + command: .buildkite/commands/run-ui-tests.sh UITests "iPad Air (5th generation)" depends_on: "build" env: *common_env plugins: *common_plugins diff --git a/CHANGELOG.md b/CHANGELOG.md index f4c8c94f3b1..bf52792ce09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ Contains editorialized release notes. Raw release notes should go into `RELEASE-NOTES.txt`. --> +## 10.3 + +More love for In-Person Payments and Analytics this time around. We fixed a bug which could prevent you from collecting payments in the app. Card reader connections are more stable. And we fixed an issue where your store's analytics are sometimes not updated. + + ## 10.2 Even though this release doesn’t have any new features, we still put a lot of love into it! You can now enable or disable the option to take card or cash payments on collection or delivery. We also added a new Help Center page that makes it easier for you to login to the app. diff --git a/Experiments/Experiments/DefaultFeatureFlagService.swift b/Experiments/Experiments/DefaultFeatureFlagService.swift index b055beff461..a7203523898 100644 --- a/Experiments/Experiments/DefaultFeatureFlagService.swift +++ b/Experiments/Experiments/DefaultFeatureFlagService.swift @@ -7,10 +7,6 @@ public struct DefaultFeatureFlagService: FeatureFlagService { switch featureFlag { case .barcodeScanner: return buildConfig == .localDeveloper || buildConfig == .alpha - case .jetpackConnectionPackageSupport: - return true - case .couponView: - return true case .productSKUInputScanner: return true case .inbox: @@ -19,20 +15,10 @@ public struct DefaultFeatureFlagService: FeatureFlagService { return buildConfig == .localDeveloper || buildConfig == .alpha case .splitViewInOrdersTab: return buildConfig == .localDeveloper || buildConfig == .alpha - case .couponDeletion: - return true - case .couponEditing: - return true - case .couponCreation: - return true case .updateOrderOptimistically: return buildConfig == .localDeveloper || buildConfig == .alpha case .shippingLabelsOnboardingM1: return buildConfig == .localDeveloper || buildConfig == .alpha - case .backgroundProductImageUpload: - return true - case .appleIDAccountDeletion: - return true case .newToWooCommerceLinkInLoginPrologue: return true case .loginPrologueOnboarding: diff --git a/Experiments/Experiments/FeatureFlag.swift b/Experiments/Experiments/FeatureFlag.swift index cf5cf04ab21..ad430055c49 100644 --- a/Experiments/Experiments/FeatureFlag.swift +++ b/Experiments/Experiments/FeatureFlag.swift @@ -14,14 +14,6 @@ public enum FeatureFlag: Int { /// case reviews - /// Allows sites with plugins that include Jetpack Connection Package and without Jetpack-the-plugin to connect to the app - /// - case jetpackConnectionPackageSupport - - /// Displays the option to view coupons - /// - case couponView - /// Barcode scanner for product SKU input /// case productSKUInputScanner @@ -38,18 +30,6 @@ public enum FeatureFlag: Int { /// case splitViewInOrdersTab - /// Displays the option to delete coupons - /// - case couponDeletion - - /// Displays the option to edit a coupon - /// - case couponEditing - - /// Displays the option to create a coupon - /// - case couponCreation - /// Enable optimistic updates for orders /// case updateOrderOptimistically @@ -58,18 +38,10 @@ public enum FeatureFlag: Int { /// case shippingLabelsOnboardingM1 - /// Enable image upload after leaving the product form - /// - case backgroundProductImageUpload - /// Enable IPP reader manuals consolidation screen /// case consolidatedCardReaderManuals - /// Apple ID account deletion - /// - case appleIDAccountDeletion - /// Showing a "New to WooCommerce" link in the login prologue screen /// case newToWooCommerceLinkInLoginPrologue diff --git a/Networking/Networking.xcodeproj/project.pbxproj b/Networking/Networking.xcodeproj/project.pbxproj index 2517ecd6714..25c2879b091 100644 --- a/Networking/Networking.xcodeproj/project.pbxproj +++ b/Networking/Networking.xcodeproj/project.pbxproj @@ -624,6 +624,20 @@ DE2095BD27956D7900171F1C /* CouponReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE2095BC27956D7900171F1C /* CouponReport.swift */; }; DE2095BF279583A100171F1C /* CouponReportListMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE2095BE279583A100171F1C /* CouponReportListMapper.swift */; }; DE2095C127966EC800171F1C /* coupon-reports.json in Resources */ = {isa = PBXBuildFile; fileRef = DE2095C027966EC800171F1C /* coupon-reports.json */; }; + DE34051328BDCA5100CF0D97 /* WordPressOrgRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE34051228BDCA5100CF0D97 /* WordPressOrgRequest.swift */; }; + DE34051528BDEB1900CF0D97 /* WordPressOrgNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE34051428BDEB1900CF0D97 /* WordPressOrgNetwork.swift */; }; + DE34051728BDEB6D00CF0D97 /* JetpackConnectionRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE34051628BDEB6D00CF0D97 /* JetpackConnectionRemote.swift */; }; + DE34051928BDEE6A00CF0D97 /* JetpackConnectionURLMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE34051828BDEE6A00CF0D97 /* JetpackConnectionURLMapper.swift */; }; + DE34051B28BDF12C00CF0D97 /* jetpack-connection-url.json in Resources */ = {isa = PBXBuildFile; fileRef = DE34051A28BDF12C00CF0D97 /* jetpack-connection-url.json */; }; + DE34051D28BDF1C900CF0D97 /* JetpackConnectionURLMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE34051C28BDF1C900CF0D97 /* JetpackConnectionURLMapperTests.swift */; }; + DE34051F28BDFB0B00CF0D97 /* JetpackConnectionRemoteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE34051E28BDFB0B00CF0D97 /* JetpackConnectionRemoteTests.swift */; }; + DE34052128BDFE3500CF0D97 /* WordPressOrgRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE34052028BDFE3500CF0D97 /* WordPressOrgRequestTests.swift */; }; + DE50295928C5BD0200551736 /* JetpackUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE50295828C5BD0200551736 /* JetpackUser.swift */; }; + DE50295B28C5F99700551736 /* DotcomUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE50295A28C5F99700551736 /* DotcomUser.swift */; }; + DE50295D28C6068B00551736 /* JetpackUserMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE50295C28C6068B00551736 /* JetpackUserMapper.swift */; }; + DE50296128C609A300551736 /* jetpack-connected-user.json in Resources */ = {isa = PBXBuildFile; fileRef = DE50295F28C609A300551736 /* jetpack-connected-user.json */; }; + DE50296328C609DE00551736 /* jetpack-user-not-connected.json in Resources */ = {isa = PBXBuildFile; fileRef = DE50296228C609DE00551736 /* jetpack-user-not-connected.json */; }; + DE50296528C60A8000551736 /* JetpackUserMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE50296428C60A8000551736 /* JetpackUserMapperTests.swift */; }; DE5CA111288A3E080077BEF9 /* product-malformed-variations-and-image-alt.json in Resources */ = {isa = PBXBuildFile; fileRef = DE5CA110288A3E080077BEF9 /* product-malformed-variations-and-image-alt.json */; }; DE6F308727966FEF004E1C9A /* CouponReportListMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6F308627966FEF004E1C9A /* CouponReportListMapperTests.swift */; }; DE74F29A27E08F5A0002FE59 /* SiteSettingMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE74F29927E08F5A0002FE59 /* SiteSettingMapper.swift */; }; @@ -1305,6 +1319,20 @@ DE2095BC27956D7900171F1C /* CouponReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CouponReport.swift; sourceTree = ""; }; DE2095BE279583A100171F1C /* CouponReportListMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CouponReportListMapper.swift; sourceTree = ""; }; DE2095C027966EC800171F1C /* coupon-reports.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "coupon-reports.json"; sourceTree = ""; }; + DE34051228BDCA5100CF0D97 /* WordPressOrgRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressOrgRequest.swift; sourceTree = ""; }; + DE34051428BDEB1900CF0D97 /* WordPressOrgNetwork.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WordPressOrgNetwork.swift; sourceTree = ""; }; + DE34051628BDEB6D00CF0D97 /* JetpackConnectionRemote.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JetpackConnectionRemote.swift; sourceTree = ""; }; + DE34051828BDEE6A00CF0D97 /* JetpackConnectionURLMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackConnectionURLMapper.swift; sourceTree = ""; }; + DE34051A28BDF12C00CF0D97 /* jetpack-connection-url.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "jetpack-connection-url.json"; sourceTree = ""; }; + DE34051C28BDF1C900CF0D97 /* JetpackConnectionURLMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackConnectionURLMapperTests.swift; sourceTree = ""; }; + DE34051E28BDFB0B00CF0D97 /* JetpackConnectionRemoteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackConnectionRemoteTests.swift; sourceTree = ""; }; + DE34052028BDFE3500CF0D97 /* WordPressOrgRequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressOrgRequestTests.swift; sourceTree = ""; }; + DE50295828C5BD0200551736 /* JetpackUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackUser.swift; sourceTree = ""; }; + DE50295A28C5F99700551736 /* DotcomUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DotcomUser.swift; sourceTree = ""; }; + DE50295C28C6068B00551736 /* JetpackUserMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackUserMapper.swift; sourceTree = ""; }; + DE50295F28C609A300551736 /* jetpack-connected-user.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "jetpack-connected-user.json"; sourceTree = ""; }; + DE50296228C609DE00551736 /* jetpack-user-not-connected.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "jetpack-user-not-connected.json"; sourceTree = ""; }; + DE50296428C60A8000551736 /* JetpackUserMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackUserMapperTests.swift; sourceTree = ""; }; DE5CA110288A3E080077BEF9 /* product-malformed-variations-and-image-alt.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "product-malformed-variations-and-image-alt.json"; sourceTree = ""; }; DE6F308627966FEF004E1C9A /* CouponReportListMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CouponReportListMapperTests.swift; sourceTree = ""; }; DE74F29927E08F5A0002FE59 /* SiteSettingMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteSettingMapper.swift; sourceTree = ""; }; @@ -1535,6 +1563,7 @@ B518662020A097B200037A38 /* Network */ = { isa = PBXGroup; children = ( + DE34051428BDEB1900CF0D97 /* WordPressOrgNetwork.swift */, B518662120A097C200037A38 /* Network.swift */, B556FD68211CE2EC00B5DAE7 /* NetworkError.swift */, B518662320A099BF00037A38 /* AlamofireNetwork.swift */, @@ -1586,6 +1615,7 @@ 31A451BC2786344B00FE81AA /* StripeRemoteTests.swift */, FE28F6EB268436C9004465C7 /* UserRemoteTests.swift */, 077F39D926A58ED700ABEADC /* SystemStatusRemoteTests.swift */, + DE34051E28BDFB0B00CF0D97 /* JetpackConnectionRemoteTests.swift */, ); path = Remote; sourceTree = ""; @@ -1603,6 +1633,7 @@ isa = PBXGroup; children = ( B567AF2C20A0FB8F00AB6C62 /* AuthenticatedRequestTests.swift */, + DE34052028BDFE3500CF0D97 /* WordPressOrgRequestTests.swift */, B567AF2D20A0FB8F00AB6C62 /* DotcomRequestTests.swift */, B567AF2E20A0FB8F00AB6C62 /* JetpackRequestTests.swift */, ); @@ -1678,6 +1709,7 @@ isa = PBXGroup; children = ( B557DA0020975500005962F4 /* Remote.swift */, + DE34051628BDEB6D00CF0D97 /* JetpackConnectionRemote.swift */, B505F6D020BEE39600BB1B69 /* AccountRemote.swift */, 2685C0FD263B5D8900D9EE97 /* AddOnGroupRemote.swift */, 740CF89821937A030023ED3A /* CommentRemote.swift */, @@ -1726,6 +1758,7 @@ B567AF2420A0CCA300AB6C62 /* AuthenticatedRequest.swift */, B557DA0E20975E07005962F4 /* DotcomRequest.swift */, B557D9FF209754FF005962F4 /* JetpackRequest.swift */, + DE34051228BDCA5100CF0D97 /* WordPressOrgRequest.swift */, ); path = Requests; sourceTree = ""; @@ -1820,6 +1853,8 @@ 0359EA0E27AAC6410048DE2D /* WCPayPaymentMethodDetails.swift */, 0359EA1027AAC6740048DE2D /* WCPayPaymentMethodType.swift */, FE28F6E126840DED004465C7 /* User.swift */, + DE50295828C5BD0200551736 /* JetpackUser.swift */, + DE50295A28C5F99700551736 /* DotcomUser.swift */, ); path = Model; sourceTree = ""; @@ -1827,6 +1862,9 @@ B559EBA820A0B5B100836CD4 /* Responses */ = { isa = PBXGroup; children = ( + DE50295F28C609A300551736 /* jetpack-connected-user.json */, + DE34051A28BDF12C00CF0D97 /* jetpack-connection-url.json */, + DE50296228C609DE00551736 /* jetpack-user-not-connected.json */, EE8A86F0286C5226003E8AA4 /* media-update-product-id-in-wordpress-site.json */, D865CE6D278CC19A002C8520 /* stripe-location-error.json */, D865CE6C278CC19A002C8520 /* stripe-location.json */, @@ -2069,6 +2107,7 @@ 45150A9D26836A57006922EA /* CountryListMapper.swift */, B524193E21AC5FE400D6FC0A /* DotcomDeviceMapper.swift */, 24F98C572502EA8800F49B68 /* FeatureFlagMapper.swift */, + DE50295C28C6068B00551736 /* JetpackUserMapper.swift */, AEF9458A27297FF6001DCCFB /* IgnoringResponseMapper.swift */, 4513382327A951B300AE5E78 /* InboxNoteMapper.swift */, 45CCFCE527A2E3710012E8CB /* InboxNoteListMapper.swift */, @@ -2141,6 +2180,7 @@ 02BE0A7A274B695F001176D2 /* WordPressMediaMapper.swift */, 02C112772742862600F4F0B4 /* WordPressSiteSettingsMapper.swift */, 0359EA1C27AADE000048DE2D /* WCPayChargeMapper.swift */, + DE34051828BDEE6A00CF0D97 /* JetpackConnectionURLMapper.swift */, ); path = Mapper; sourceTree = ""; @@ -2260,6 +2300,8 @@ CC0786C8267BB32800BA9AC1 /* ShippingLabelStatusMapperTests.swift */, 02C254D22563992900A04423 /* OrderShippingLabelListMapperTests.swift */, FE28F6E926842E49004465C7 /* UserMapperTests.swift */, + DE34051C28BDF1C900CF0D97 /* JetpackConnectionURLMapperTests.swift */, + DE50296428C60A8000551736 /* JetpackUserMapperTests.swift */, 0359EA1E27AAE4680048DE2D /* WCPayChargeMapperTests.swift */, ); path = Mapper; @@ -2464,8 +2506,10 @@ 45ED4F12239E8C57004F1BE3 /* taxes-classes.json in Resources */, B5A2417B217F98FC00595DEF /* broken-notifications.json in Resources */, 3158A4A32729F42500C3CFA8 /* wcpay-account-dev-test.json in Resources */, + DE50296328C609DE00551736 /* jetpack-user-not-connected.json in Resources */, 31104E142630DDA700587C1E /* wcpay-account-wrong-json.json in Resources */, 3158FE7C26129E2100E566B9 /* wcpay-account-restricted-pending.json in Resources */, + DE34051B28BDF12C00CF0D97 /* jetpack-connection-url.json in Resources */, D823D91422377EE600C90817 /* shipment_tracking_providers.json in Resources */, 4599FC5C24A6276F0056157A /* product-tags-all.json in Resources */, 03DCB77E262738E300C8953D /* coupon.json in Resources */, @@ -2505,6 +2549,7 @@ D865CE69278CA245002C8520 /* stripe-payment-intent-unknown-status.json in Resources */, 0205021C27C86B9700FB1C6B /* inbox-note-without-isRead.json in Resources */, 24F98C622502EFF600F49B68 /* feature-flags-load-all.json in Resources */, + DE50296128C609A300551736 /* jetpack-connected-user.json in Resources */, B58D10C82114D21D00107ED4 /* generic_error.json in Resources */, 077F39D826A58EB600ABEADC /* systemStatus.json in Resources */, 31A451CD27863A2E00FE81AA /* stripe-account-restricted.json in Resources */, @@ -2749,6 +2794,7 @@ 26455E2425F66982008A1D32 /* ProductAttributeTermRemote.swift in Sources */, 7426CA0D21AF27B9004E9FFC /* SiteAPIRemote.swift in Sources */, 451A97D1260A03900059D135 /* ShippingLabelCustomPackage.swift in Sources */, + DE34051328BDCA5100CF0D97 /* WordPressOrgRequest.swift in Sources */, D88D5A45230BC6F9007B6E01 /* ProductReviewsRemote.swift in Sources */, B59325D4217E4206000B0E8E /* NoteBlock.swift in Sources */, DEC51AF92769A212009F3DF4 /* SystemStatus+Settings.swift in Sources */, @@ -2903,6 +2949,7 @@ 31884A3B2603F3C7003FE338 /* SitePluginStatusEnum.swift in Sources */, 3192F21C260D32550067FEF9 /* WCPayAccountMapper.swift in Sources */, CE50345E21B571A7007573C6 /* SitePlanMapper.swift in Sources */, + DE34051528BDEB1900CF0D97 /* WordPressOrgNetwork.swift in Sources */, 3192F224260D34C40067FEF9 /* WCPayAccountStatusEnum.swift in Sources */, D8FBFF2022D52553006E3336 /* OrderStatsV4Totals.swift in Sources */, 02C254B925637BA000A04423 /* OrderShippingLabelListMapper.swift in Sources */, @@ -2969,11 +3016,13 @@ 026CF61A237D607A009563D4 /* ProductVariationAttribute.swift in Sources */, D8FBFF1A22D4DF7A006E3336 /* OrderStatsV4.swift in Sources */, 74A1D26B21189B8100931DFA /* SiteVisitStatsItem.swift in Sources */, + DE34051728BDEB6D00CF0D97 /* JetpackConnectionRemote.swift in Sources */, B505F6EC20BEFDC200BB1B69 /* Loader.swift in Sources */, 74D3BD142114FE6900A6E85E /* MIContainer.swift in Sources */, 314703082670222500EF253A /* PaymentGatewayAccount.swift in Sources */, CCF48B282628A4EB0034EA83 /* ShippingLabelAccountSettingsMapper.swift in Sources */, B5BB1D1220A255EC00112D92 /* OrderStatusEnum.swift in Sources */, + DE50295928C5BD0200551736 /* JetpackUser.swift in Sources */, 0359EA1727AAC7740048DE2D /* WCPayCardFunding.swift in Sources */, D88E229425AC9B420023F3B1 /* OrderFeeTaxStatus.swift in Sources */, B5DAEFF02180DD5A0002356A /* NotificationsRemote.swift in Sources */, @@ -2981,6 +3030,7 @@ 020D07BE23D8570800FD9580 /* MediaListMapper.swift in Sources */, 0359EA1327AAC6D00048DE2D /* WCPayCardPaymentDetails.swift in Sources */, CCB2CA9E262091CB00285CA0 /* SuccessDataResultMapper.swift in Sources */, + DE50295B28C5F99700551736 /* DotcomUser.swift in Sources */, 74C8F06820EEB7BD00B6EDC9 /* OrderNotesMapper.swift in Sources */, 24F98C582502EA8800F49B68 /* FeatureFlagMapper.swift in Sources */, 451A9832260B9D2D0059D135 /* ShippingLabelPackagesMapper.swift in Sources */, @@ -2989,6 +3039,7 @@ 74046E1D217A6989007DD7BF /* SiteSetting.swift in Sources */, B5BB1D1020A237FB00112D92 /* Address.swift in Sources */, CE43066A23465F340073CBFF /* Refund.swift in Sources */, + DE50295D28C6068B00551736 /* JetpackUserMapper.swift in Sources */, B524194121AC60A700D6FC0A /* DotcomDevice.swift in Sources */, D8EDFE2225EE88C9003D2213 /* ReaderConnectionToken.swift in Sources */, 4599FC5824A624BD0056157A /* ProductTagListMapper.swift in Sources */, @@ -3016,6 +3067,7 @@ DEC51AE927687AAF009F3DF4 /* SystemPluginMapper.swift in Sources */, B557DA0B20975D7E005962F4 /* WooAPIVersion.swift in Sources */, 45150A9E26836A57006922EA /* CountryListMapper.swift in Sources */, + DE34051928BDEE6A00CF0D97 /* JetpackConnectionURLMapper.swift in Sources */, CE6BFEE82236D133005C79FB /* ProductDimensions.swift in Sources */, 077F39D426A58DE700ABEADC /* SystemStatusMapper.swift in Sources */, 45152811257A81730076B03C /* ProductAttributeMapper.swift in Sources */, @@ -3051,6 +3103,7 @@ buildActionMask = 2147483647; files = ( 45551F142523E7FF007EF104 /* UserAgentTests.swift in Sources */, + DE34051D28BDF1C900CF0D97 /* JetpackConnectionURLMapperTests.swift in Sources */, 451A9836260B9DF90059D135 /* ShippingLabelPackagesMapperTests.swift in Sources */, 02BDB83723EA9C4D00BCC63E /* String+HTMLTests.swift in Sources */, 74CF5E8421402C04000CED0A /* TopEarnerStatsRemoteTests.swift in Sources */, @@ -3087,6 +3140,7 @@ 0359EA1F27AAE4680048DE2D /* WCPayChargeMapperTests.swift in Sources */, 31D27C952602B737002EDB1D /* SitePluginsRemoteTests.swift in Sources */, DEC51AFB2769C66B009F3DF4 /* SystemStatusMapperTests.swift in Sources */, + DE34051F28BDFB0B00CF0D97 /* JetpackConnectionRemoteTests.swift in Sources */, 74AB5B4D21AF354E00859C12 /* SiteAPIMapperTests.swift in Sources */, 93D8BC01226BC20600AD2EB3 /* AccountSettingsRemoteTests.swift in Sources */, 262E5AD5255ACD6F000B2416 /* PaymentGatewayListMapperTests.swift in Sources */, @@ -3108,6 +3162,7 @@ 7412A8EE21B6E335005D182A /* ReportOrderMapperTests.swift in Sources */, AED8AEBC272A997500663FCC /* IgnoringResponseMapperTests.swift in Sources */, 74CF0A8C22414D7800DB993F /* ProductMapperTests.swift in Sources */, + DE34052128BDFE3500CF0D97 /* WordPressOrgRequestTests.swift in Sources */, 45152815257A83DD0076B03C /* ProductAttributesRemoteTests.swift in Sources */, B505F6D720BEE58800BB1B69 /* AccountRemoteTests.swift in Sources */, 453305EB2459E01A00264E50 /* PostMapperTests.swift in Sources */, @@ -3133,6 +3188,7 @@ B518663520A0A2E800037A38 /* Constants.swift in Sources */, D8FBFF1E22D51F39006E3336 /* OrderStatsMapperV4Tests.swift in Sources */, 02E7FFCB256218F600C53030 /* ShippingLabelRemoteTests.swift in Sources */, + DE50296528C60A8000551736 /* JetpackUserMapperTests.swift in Sources */, 9387A6F0226E3F15001B53D7 /* AccountSettingsMapperTests.swift in Sources */, 2685C102263B6A1000D9EE97 /* AddOnGroupRemoteTests.swift in Sources */, B57B1E6721C916850046E764 /* NetworkErrorTests.swift in Sources */, diff --git a/Networking/Networking/Mapper/JetpackConnectionURLMapper.swift b/Networking/Networking/Mapper/JetpackConnectionURLMapper.swift new file mode 100644 index 00000000000..e1ba251fe0b --- /dev/null +++ b/Networking/Networking/Mapper/JetpackConnectionURLMapper.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Mapper: Jetpack Connection URL +/// +struct JetpackConnectionURLMapper: Mapper { + + /// (Attempts) to convert the response into a URL. + /// + func map(response: Data) throws -> URL { + guard let escapedString = String(data: response, encoding: .utf8) else { + throw JetpackConnectionRemote.ConnectionError.malformedURL + } + // The API returns an escaped string with double quotes, so we need to clean it up. + let urlString = escapedString + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "\"", with: "") + .replacingOccurrences(of: "\\", with: "") + return try urlString.asURL() + } +} diff --git a/Networking/Networking/Mapper/JetpackUserMapper.swift b/Networking/Networking/Mapper/JetpackUserMapper.swift new file mode 100644 index 00000000000..b97a2a6e050 --- /dev/null +++ b/Networking/Networking/Mapper/JetpackUserMapper.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Mapper: Jetpack user +/// +struct JetpackUserMapper: Mapper { + + /// (Attempts) to extract the updated `currentUser` field from a given JSON Encoded response. + /// + func map(response: Data) throws -> JetpackUser { + let decoder = JSONDecoder() + return try decoder.decode(JetpackConnectionData.self, from: response).currentUser + } +} + +/// JetpackConnectionData Disposable Entity: +/// This entity allows us to parse JetpackUser with JSONDecoder. +/// +private struct JetpackConnectionData: Decodable { + let currentUser: JetpackUser + + private enum CodingKeys: String, CodingKey { + case currentUser + } +} diff --git a/Networking/Networking/Model/Copiable/Models+Copiable.generated.swift b/Networking/Networking/Model/Copiable/Models+Copiable.generated.swift index 2d0b093381e..8eb39c4e25e 100644 --- a/Networking/Networking/Model/Copiable/Models+Copiable.generated.swift +++ b/Networking/Networking/Model/Copiable/Models+Copiable.generated.swift @@ -169,6 +169,30 @@ extension CouponReport { } } +extension DotcomUser { + public func copy( + id: CopiableProp = .copy, + username: CopiableProp = .copy, + email: CopiableProp = .copy, + displayName: CopiableProp = .copy, + avatar: NullableCopiableProp = .copy + ) -> DotcomUser { + let id = id ?? self.id + let username = username ?? self.username + let email = email ?? self.email + let displayName = displayName ?? self.displayName + let avatar = avatar ?? self.avatar + + return DotcomUser( + id: id, + username: username, + email: email, + displayName: displayName, + avatar: avatar + ) + } +} + extension InboxAction { public func copy( id: CopiableProp = .copy, @@ -235,6 +259,30 @@ extension InboxNote { } } +extension JetpackUser { + public func copy( + isConnected: CopiableProp = .copy, + isPrimary: CopiableProp = .copy, + username: CopiableProp = .copy, + wpcomUser: NullableCopiableProp = .copy, + gravatar: NullableCopiableProp = .copy + ) -> JetpackUser { + let isConnected = isConnected ?? self.isConnected + let isPrimary = isPrimary ?? self.isPrimary + let username = username ?? self.username + let wpcomUser = wpcomUser ?? self.wpcomUser + let gravatar = gravatar ?? self.gravatar + + return JetpackUser( + isConnected: isConnected, + isPrimary: isPrimary, + username: username, + wpcomUser: wpcomUser, + gravatar: gravatar + ) + } +} + extension Media { public func copy( mediaID: CopiableProp = .copy, diff --git a/Networking/Networking/Model/DotcomUser.swift b/Networking/Networking/Model/DotcomUser.swift new file mode 100644 index 00000000000..ca9c54cd57b --- /dev/null +++ b/Networking/Networking/Model/DotcomUser.swift @@ -0,0 +1,43 @@ +import Codegen +import Foundation + +/// Basic information of a WordPress.com user +public struct DotcomUser: Decodable, GeneratedFakeable, GeneratedCopiable { + + /// User ID in WP.com + public let id: Int64 + + /// Username in WP.com + public let username: String + + /// Registered email address with WP.com + public let email: String + + /// Display name in WP.com + public let displayName: String + + /// Link to avatar used in WP.com + public let avatar: String? + + /// Member-wise initializer + public init(id: Int64, username: String, email: String, displayName: String, avatar: String?) { + self.id = id + self.username = username + self.email = email + self.displayName = displayName + self.avatar = avatar + } +} + +/// Defines all of the `WordPressComUser` CodingKeys. +/// +private extension DotcomUser { + + enum CodingKeys: String, CodingKey { + case id = "ID" + case username = "login" + case email + case displayName = "display_name" + case avatar + } +} diff --git a/Networking/Networking/Model/JetpackUser.swift b/Networking/Networking/Model/JetpackUser.swift new file mode 100644 index 00000000000..2c9b2687baa --- /dev/null +++ b/Networking/Networking/Model/JetpackUser.swift @@ -0,0 +1,52 @@ +import Codegen +import Foundation + +/// Information of a WP.com user connected to a site's Jetpack if exists +public struct JetpackUser: Decodable, GeneratedFakeable, GeneratedCopiable { + + /// Whether the user has connected a WP.com account to the site's Jetpack + public let isConnected: Bool + + /// Whether the user is the primary account connected to the site's Jetpack + public let isPrimary: Bool + + /// WP.org username in the site. + public let username: String + + /// The connected WP.com user if exists + public let wpcomUser: DotcomUser? + + /// Gravatar link of the user + public let gravatar: String? + + /// Member-wise initializer + public init(isConnected: Bool, isPrimary: Bool, username: String, wpcomUser: DotcomUser?, gravatar: String?) { + self.isConnected = isConnected + self.isPrimary = isPrimary + self.username = username + self.wpcomUser = wpcomUser + self.gravatar = gravatar + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + isConnected = try container.decode(Bool.self, forKey: .isConnected) + isPrimary = try container.decode(Bool.self, forKey: .isPrimary) + username = try container.decode(String.self, forKey: .username) + wpcomUser = try? container.decode(DotcomUser.self, forKey: .wpcomUser) + gravatar = try? container.decode(String.self, forKey: .gravatar) + } +} + +/// Defines all of the `JetpackUser` CodingKeys. +/// +private extension JetpackUser { + + enum CodingKeys: String, CodingKey { + case isConnected + case isPrimary = "isMaster" + case username + case wpcomUser + case gravatar + } +} diff --git a/Networking/Networking/Network/AlamofireNetwork.swift b/Networking/Networking/Network/AlamofireNetwork.swift index ce738f976ad..06194113da8 100644 --- a/Networking/Networking/Network/AlamofireNetwork.swift +++ b/Networking/Networking/Network/AlamofireNetwork.swift @@ -107,9 +107,9 @@ public class AlamofireNetwork: Network { } -/// MARK: - Alamofire.DataResponse: Private Methods -/// -private extension Alamofire.DataResponse { +// MARK: - Alamofire.DataResponse: Helper Methods +// +extension Alamofire.DataResponse { /// Returns the Networking Layer Error (if any): /// @@ -136,8 +136,8 @@ private extension Alamofire.DataResponse { } // MARK: - Swift.Result Conversion - -private extension Alamofire.Result { +// +extension Alamofire.Result { /// Convert this `Alamofire.Result` to a `Swift.Result`. /// func toSwiftResult() -> Swift.Result { diff --git a/Networking/Networking/Network/MockNetwork.swift b/Networking/Networking/Network/MockNetwork.swift index 35c9bd33ff6..af1ba65c09b 100644 --- a/Networking/Networking/Network/MockNetwork.swift +++ b/Networking/Networking/Network/MockNetwork.swift @@ -27,13 +27,6 @@ class MockNetwork: Network { /// var requestsForResponseData = [URLRequestConvertible]() - - /// Public Initializer - /// - required init(credentials: Credentials) { } - - /// Dummy convenience initializer. Remember: Real Network wrappers will always need credentials! - /// /// Note: If the useResponseQueue param is `true`, any responses added via `simulateResponse` will stored in a FIFO queue /// and used once for a matching request (then removed from the queue). Subsequent requests will use the next response in the queue, and so on. /// @@ -42,9 +35,7 @@ class MockNetwork: Network { /// /// - Parameter useResponseQueue: Use the response queue. Default is `false`. /// - convenience init(useResponseQueue: Bool = false) { - let dummy = Credentials(username: "", authToken: "", siteAddress: "") - self.init(credentials: dummy) + init(useResponseQueue: Bool = false) { self.useResponseQueue = useResponseQueue } diff --git a/Networking/Networking/Network/Network.swift b/Networking/Networking/Network/Network.swift index 09864f44cfe..5dcc0d37f33 100644 --- a/Networking/Networking/Network/Network.swift +++ b/Networking/Networking/Network/Network.swift @@ -19,13 +19,6 @@ public protocol MultipartFormData { /// public protocol Network { - /// Designated Initializer. - /// - /// - Parameters: - /// - credentials: WordPress.com Credentials. - /// - init(credentials: Credentials) - /// Executes the specified Network Request. Upon completion, the payload will be sent back to the caller as a Data instance. /// /// - Parameters: diff --git a/Networking/Networking/Network/NullNetwork.swift b/Networking/Networking/Network/NullNetwork.swift index f71ee1f3de7..a1e76a641d3 100644 --- a/Networking/Networking/Network/NullNetwork.swift +++ b/Networking/Networking/Network/NullNetwork.swift @@ -8,7 +8,6 @@ import Alamofire /// public final class NullNetwork: Network { public init() { } - public required init(credentials: Credentials) { } public func responseData(for request: URLRequestConvertible, completion: @escaping (Data?, Error?) -> Void) { } diff --git a/Networking/Networking/Network/WordPressOrgNetwork.swift b/Networking/Networking/Network/WordPressOrgNetwork.swift new file mode 100644 index 00000000000..f1a31dd413b --- /dev/null +++ b/Networking/Networking/Network/WordPressOrgNetwork.swift @@ -0,0 +1,136 @@ +import Alamofire +import Combine +import Foundation +import WordPressKit + +/// Class to handle WP.org REST API requests. +/// +public final class WordPressOrgNetwork: Network { + + private let authenticator: Authenticator? + private let userAgent: String? + + private lazy var sessionManager: Alamofire.SessionManager = { + let sessionConfiguration = URLSessionConfiguration.default + let sessionManager = makeSessionManager(configuration: sessionConfiguration) + return sessionManager + }() + + private lazy var backgroundSessionManager: Alamofire.SessionManager = { + // A unique ID is included in the background session identifier so that the session does not get invalidated when the initializer is called multiple + // times (e.g. when logging in). + let uniqueID = UUID().uuidString + let sessionConfiguration = URLSessionConfiguration.background(withIdentifier: "com.automattic.woocommerce.backgroundsession.\(uniqueID)") + let sessionManager = makeSessionManager(configuration: sessionConfiguration) + return sessionManager + }() + + public init(authenticator: Authenticator? = nil, userAgent: String = UserAgent.defaultUserAgent) { + self.authenticator = authenticator + self.userAgent = userAgent + } + + public func responseData(for request: URLRequestConvertible) async throws -> Data? { + return try await withCheckedThrowingContinuation { [weak self] continuation in + guard let self = self else { return } + + self.sessionManager.request(request) + .validate() + .responseData(completionHandler: { (response) in + switch response.result { + case .success(let responseObject): + continuation.resume(returning: responseObject) + case .failure(let error): + DDLogWarn("⚠️ Error requesting \(request.urlRequest?.url?.absoluteString ?? ""): \(error.localizedDescription)") + continuation.resume(throwing: error) + } + + }) + } + } + + /// Executes the specified Network Request. Upon completion, the payload will be sent back to the caller as a Data instance. + /// + /// - Important: + /// - User agent and authenticator from the initializer will be injected through the `validate` call. + /// + /// - Parameters: + /// - request: Request that should be performed. + /// - completion: Closure to be executed upon completion. + /// + public func responseData(for request: URLRequestConvertible, completion: @escaping (Data?, Error?) -> Void) { + sessionManager.request(request) + .validate() + .responseData { response in + completion(response.value, response.networkingError) + } + } + + /// Executes the specified Network Request. Upon completion, the payload will be sent back to the caller as a Data instance. + /// + /// - Important: + /// - User agent and authenticator from the initializer will be injected through the `validate` call.. + /// + /// - Parameters: + /// - request: Request that should be performed. + /// - completion: Closure to be executed upon completion. + /// + public func responseData(for request: URLRequestConvertible, completion: @escaping (Swift.Result) -> Void) { + sessionManager.request(request) + .validate() + .responseData { response in + completion(response.result.toSwiftResult()) + } + } + + /// Executes the specified Network Request. Upon completion, the payload or error will be emitted to the publisher. + /// Only one value will be emitted and the request cannot be retried. + /// + /// - Important: + /// - User agent and authenticator from the initializer will be injected through the `validate` call.. + /// + /// - Parameter request: Request that should be performed. + /// - Returns: A publisher that emits the result of the given request. + public func responseDataPublisher(for request: URLRequestConvertible) -> AnyPublisher, Never> { + return Future() { [weak self] promise in + guard let self = self else { return } + self.sessionManager.request(request).validate().responseData { response in + let result = response.result.toSwiftResult() + promise(Swift.Result.success(result)) + } + }.eraseToAnyPublisher() + } + + public func uploadMultipartFormData(multipartFormData: @escaping (MultipartFormData) -> Void, + to request: URLRequestConvertible, + completion: @escaping (Data?, Error?) -> Void) { + backgroundSessionManager.upload(multipartFormData: multipartFormData, with: request) { (encodingResult) in + switch encodingResult { + case .success(let upload, _, _): + upload.responseData { response in + completion(response.value, response.error) + } + case .failure(let error): + completion(nil, error) + } + } + } +} + +private extension WordPressOrgNetwork { + /// Creates a session manager with injected user agent and authenticator for handling cookie-nonce/token + /// + func makeSessionManager(configuration sessionConfiguration: URLSessionConfiguration) -> Alamofire.SessionManager { + var additionalHeaders: [String: AnyObject] = [:] + if let userAgent = self.userAgent { + additionalHeaders["User-Agent"] = userAgent as AnyObject? + } + + sessionConfiguration.httpAdditionalHeaders = additionalHeaders + + let sessionManager = Alamofire.SessionManager(configuration: sessionConfiguration) + sessionManager.adapter = authenticator + sessionManager.retrier = authenticator + return sessionManager + } +} diff --git a/Networking/Networking/Remote/JetpackConnectionRemote.swift b/Networking/Networking/Remote/JetpackConnectionRemote.swift new file mode 100644 index 00000000000..59b421079ff --- /dev/null +++ b/Networking/Networking/Remote/JetpackConnectionRemote.swift @@ -0,0 +1,43 @@ +import Foundation +import WordPressKit + +/// Handle API requests to the Jetpack REST API. +/// +public final class JetpackConnectionRemote: Remote { + private let siteURL: String + + public init(siteURL: String, network: Network) { + self.siteURL = siteURL + super.init(network: network) + } + + /// Fetches the URL for setting up Jetpack connection. + /// + public func fetchJetpackConnectionURL(completion: @escaping (Result) -> Void) { + let request = WordPressOrgRequest(baseURL: siteURL, method: .get, path: Path.jetpackConnectionURL) + let mapper = JetpackConnectionURLMapper() + + enqueue(request, mapper: mapper, completion: completion) + } + + /// Fetches the user connection state with the site's Jetpack. + /// + public func fetchJetpackUser(completion: @escaping (Result) -> Void) { + let request = WordPressOrgRequest(baseURL: siteURL, method: .get, path: Path.jetpackConnectionUser) + let mapper = JetpackUserMapper() + enqueue(request, mapper: mapper, completion: completion) + } +} + +public extension JetpackConnectionRemote { + enum ConnectionError: Int, Error { + case malformedURL + } +} + +private extension JetpackConnectionRemote { + enum Path { + static let jetpackConnectionURL = "/jetpack/v4/connection/url" + static let jetpackConnectionUser = "/jetpack/v4/connection/data" + } +} diff --git a/Networking/Networking/Requests/WordPressOrgRequest.swift b/Networking/Networking/Requests/WordPressOrgRequest.swift new file mode 100644 index 00000000000..61c6a2adbd1 --- /dev/null +++ b/Networking/Networking/Requests/WordPressOrgRequest.swift @@ -0,0 +1,39 @@ +import Foundation +import Alamofire + +/// Represents a WordPress.org REST API Endpoint +/// +struct WordPressOrgRequest: URLRequestConvertible { + + /// Base URL for the endpoint + /// + let baseURL: String + + /// HTTP Request Method + /// + let method: HTTPMethod + + /// Path to endpoint + /// + let path: String + + /// Parameters + /// + var parameters: [String: Any]? + + + /// Returns a URLRequest instance reprensenting the current WordPress.org REST API Request. + /// + func asURLRequest() throws -> URLRequest { + let url = URL(string: baseURL + Settings.basePath + path.removingPrefix("/"))! + let request = try URLRequest(url: url, method: method, headers: nil) + + return try URLEncoding.default.encode(request, with: parameters) + } +} + +private extension WordPressOrgRequest { + enum Settings { + static let basePath = "/wp-json/" + } +} diff --git a/Networking/NetworkingTests/Mapper/JetpackConnectionURLMapperTests.swift b/Networking/NetworkingTests/Mapper/JetpackConnectionURLMapperTests.swift new file mode 100644 index 00000000000..a2f9d4770b7 --- /dev/null +++ b/Networking/NetworkingTests/Mapper/JetpackConnectionURLMapperTests.swift @@ -0,0 +1,26 @@ +import XCTest +@testable import Networking + +/// JetpackConnectionURLMapper Unit Tests +/// +final class JetpackConnectionURLMapperTests: XCTestCase { + + func test_url_is_properly_parsed() { + guard let url = mapURLFromMockResponse() else { + XCTFail() + return + } + let expectedURL = "https://jetpack.wordpress.com/jetpack.authorize/1/?response_type=code&client_id=2099457" + assertEqual(url.absoluteString, expectedURL) + } +} + +private extension JetpackConnectionURLMapperTests { + func mapURLFromMockResponse() -> URL? { + guard let response = Loader.contentsOf("jetpack-connection-url") else { + return nil + } + + return try? JetpackConnectionURLMapper().map(response: response) + } +} diff --git a/Networking/NetworkingTests/Mapper/JetpackUserMapperTests.swift b/Networking/NetworkingTests/Mapper/JetpackUserMapperTests.swift new file mode 100644 index 00000000000..8897b3dc0b8 --- /dev/null +++ b/Networking/NetworkingTests/Mapper/JetpackUserMapperTests.swift @@ -0,0 +1,57 @@ +import XCTest +@testable import Networking + +/// JetpackUserMapper Unit Tests +/// +final class JetpackUserMapperTests: XCTestCase { + + func test_all_fields_are_parsed_properly_when_user_is_connected() throws { + // Given + let user = try mapUserFromMockResponse() + let wpcomUser = try XCTUnwrap(user.wpcomUser) + + // Then + XCTAssertEqual(user.username, "admin") + XCTAssertEqual(user.gravatar, "") + XCTAssertTrue(user.isPrimary) + XCTAssertTrue(user.isConnected) + + XCTAssertEqual(wpcomUser.id, 223) + XCTAssertEqual(wpcomUser.username, "test") + XCTAssertEqual(wpcomUser.email, "test@gmail.com") + XCTAssertEqual(wpcomUser.displayName, "Test") + XCTAssertEqual(wpcomUser.avatar, "http://2.gravatar.com/avatar/5e1a8fhjd") + } + + func test_all_fields_are_parsed_properly_when_user_is_not_connected() throws { + // Given + let user = try mapNotConnectedUserFromMockResponse() + + // Then + XCTAssertFalse(user.isPrimary) + XCTAssertFalse(user.isConnected) + XCTAssertEqual(user.username, "test") + XCTAssertEqual(user.gravatar, "https://secure.gravatar.com/avatar/a7839e14") + XCTAssertNil(user.wpcomUser) + } +} + +private extension JetpackUserMapperTests { + func mapUserFromMockResponse() throws -> JetpackUser { + guard let response = Loader.contentsOf("jetpack-connected-user") else { + throw FileNotFoundError() + } + + return try JetpackUserMapper().map(response: response) + } + + func mapNotConnectedUserFromMockResponse() throws -> JetpackUser { + guard let response = Loader.contentsOf("jetpack-user-not-connected") else { + throw FileNotFoundError() + } + + return try JetpackUserMapper().map(response: response) + } + + struct FileNotFoundError: Error {} +} diff --git a/Networking/NetworkingTests/Remote/JetpackConnectionRemoteTests.swift b/Networking/NetworkingTests/Remote/JetpackConnectionRemoteTests.swift new file mode 100644 index 00000000000..734284aa7f9 --- /dev/null +++ b/Networking/NetworkingTests/Remote/JetpackConnectionRemoteTests.swift @@ -0,0 +1,97 @@ +import XCTest +@testable import Networking + +final class JetpackConnectionRemoteTests: XCTestCase { + + /// Dummy Network Wrapper + /// + let network = MockNetwork() + + /// Repeat always! + /// + override func setUp() { + network.removeAllSimulatedResponses() + } + + func test_fetchJetpackConnectionURL_correctly_returns_parsed_url() throws { + // Given + let siteURL = "http://test.com" + let remote = JetpackConnectionRemote(siteURL: siteURL, network: network) + let urlSuffix = "/jetpack/v4/connection/url" + network.simulateResponse(requestUrlSuffix: urlSuffix, filename: "jetpack-connection-url") + + // When + let result: Result = waitFor { promise in + remote.fetchJetpackConnectionURL { result in + promise(result) + } + } + + // Then + XCTAssertTrue(result.isSuccess) + let url = try XCTUnwrap(result.get()) + let expectedURL = "https://jetpack.wordpress.com/jetpack.authorize/1/?response_type=code&client_id=2099457" + assertEqual(url.absoluteString, expectedURL) + } + + func test_fetchJetpackConnectionURL_properly_relays_errors() { + // Given + let siteURL = "http://test.com" + let remote = JetpackConnectionRemote(siteURL: siteURL, network: network) + let urlSuffix = "/jetpack/v4/connection/url" + let error = NetworkError.unacceptableStatusCode(statusCode: 500) + network.simulateError(requestUrlSuffix: urlSuffix, error: error) + + // When + let result: Result = waitFor { promise in + remote.fetchJetpackConnectionURL { result in + promise(result) + } + } + + // Then + XCTAssertTrue(result.isFailure) + XCTAssertEqual(result.failure as? NetworkError, error) + } + + func test_fetchJetpackUser_correctly_returns_parsed_user() throws { + // Given + let siteURL = "http://test.com" + let remote = JetpackConnectionRemote(siteURL: siteURL, network: network) + let urlSuffix = "/jetpack/v4/connection/data" + network.simulateResponse(requestUrlSuffix: urlSuffix, filename: "jetpack-connected-user") + + // When + let result: Result = waitFor { promise in + remote.fetchJetpackUser { result in + promise(result) + } + } + + // Then + XCTAssertTrue(result.isSuccess) + let user = try XCTUnwrap(result.get()) + XCTAssertTrue(user.isConnected) + XCTAssertNotNil(user.wpcomUser) + } + + func test_fetchJetpackUser_properly_relays_errors() { + // Given + let siteURL = "http://test.com" + let remote = JetpackConnectionRemote(siteURL: siteURL, network: network) + let urlSuffix = "/jetpack/v4/connection/data" + let error = NetworkError.unacceptableStatusCode(statusCode: 500) + network.simulateError(requestUrlSuffix: urlSuffix, error: error) + + // When + let result: Result = waitFor { promise in + remote.fetchJetpackUser { result in + promise(result) + } + } + + // Then + XCTAssertTrue(result.isFailure) + XCTAssertEqual(result.failure as? NetworkError, error) + } +} diff --git a/Networking/NetworkingTests/Requests/WordPressOrgRequestTests.swift b/Networking/NetworkingTests/Requests/WordPressOrgRequestTests.swift new file mode 100644 index 00000000000..4a3248177e7 --- /dev/null +++ b/Networking/NetworkingTests/Requests/WordPressOrgRequestTests.swift @@ -0,0 +1,31 @@ +import XCTest +@testable import Networking + +final class WordPressOrgRequestTests: XCTestCase { + + private let baseURL = "http://test.com" + private let path = "/test/request" + + func test_request_url_is_correct() throws { + // Given + let request = WordPressOrgRequest(baseURL: baseURL, method: .get, path: path) + + // When + let url = try XCTUnwrap(request.asURLRequest().url) + + // Then + let expectedURL = "http://test.com/wp-json/test/request" + assertEqual(url.absoluteString, expectedURL) + } + + func test_request_method_is_correct() throws { + // Given + let request = WordPressOrgRequest(baseURL: baseURL, method: .get, path: path) + + // When + let urlRequest = try request.asURLRequest() + + // Then + assertEqual(urlRequest.httpMethod, "GET") + } +} diff --git a/Networking/NetworkingTests/Responses/jetpack-connected-user.json b/Networking/NetworkingTests/Responses/jetpack-connected-user.json new file mode 100644 index 00000000000..a12f5f8dcc5 --- /dev/null +++ b/Networking/NetworkingTests/Responses/jetpack-connected-user.json @@ -0,0 +1,31 @@ +{ + "currentUser": { + "isConnected": true, + "isMaster": true, + "username": "admin", + "wpcomUser": { + "ID": 223, + "login": "test", + "email": "test@gmail.com", + "display_name": "Test", + "text_direction": "ltr", + "site_count": 12, + "jetpack_connect": "", + "avatar": "http://2.gravatar.com/avatar/5e1a8fhjd" + }, + "gravatar": "", + "permissions": { + "admin_page": true, + "connect": true, + "disconnect": true, + "manage_modules": true, + "network_admin": false, + "network_sites_page": false, + "edit_posts": true, + "publish_posts": true, + "manage_options": true, + "view_stats": true, + "manage_plugins": true + } + } +} diff --git a/Networking/NetworkingTests/Responses/jetpack-connection-url.json b/Networking/NetworkingTests/Responses/jetpack-connection-url.json new file mode 100644 index 00000000000..3fe7dd79cd0 --- /dev/null +++ b/Networking/NetworkingTests/Responses/jetpack-connection-url.json @@ -0,0 +1 @@ +"https://jetpack.wordpress.com/jetpack.authorize/1/?response_type=code&client_id=2099457" diff --git a/Networking/NetworkingTests/Responses/jetpack-user-not-connected.json b/Networking/NetworkingTests/Responses/jetpack-user-not-connected.json new file mode 100644 index 00000000000..11fd4df6490 --- /dev/null +++ b/Networking/NetworkingTests/Responses/jetpack-user-not-connected.json @@ -0,0 +1,27 @@ +{ + "currentUser": { + "isConnected": false, + "isMaster": false, + "username": "test", + "id": 2, + "wpcomUser": { + "avatar": false + }, + "gravatar": "https://secure.gravatar.com/avatar/a7839e14", + "permissions": { + "connect": false, + "connect_user": true, + "disconnect": false, + "admin_page": true, + "manage_modules": false, + "network_admin": false, + "network_sites_page": false, + "edit_posts": true, + "publish_posts": true, + "manage_options": false, + "view_stats": false, + "manage_plugins": false + } + }, + "connectionOwner": "demo" +} diff --git a/Podfile b/Podfile index 8b8efb1ab3b..947faff2a63 100644 --- a/Podfile +++ b/Podfile @@ -27,12 +27,16 @@ def aztec end def tracks - pod 'Automattic-Tracks-iOS', '~> 0.12.0' + pod 'Automattic-Tracks-iOS', '~> 0.12.1-beta.2' # pod 'Automattic-Tracks-iOS', :git => 'https://github.com/Automattic/Automattic-Tracks-iOS.git', :branch => '' # pod 'Automattic-Tracks-iOS', :git => 'https://github.com/Automattic/Automattic-Tracks-iOS.git', :commit => '' # pod 'Automattic-Tracks-iOS', :path => '../Automattic-Tracks-iOS' end +def keychain + pod 'KeychainAccess', '~> 4.2.2' +end + # Main Target! # ============ # @@ -48,8 +52,8 @@ target 'WooCommerce' do pod 'Gridicons', '~> 1.2.0' # To allow pod to pick up beta versions use -beta. E.g., 1.1.7-beta.1 - pod 'WordPressAuthenticator', '~> 2.4.0' - # pod 'WordPressAuthenticator', :git => 'https://github.com/wordpress-mobile/WordPressAuthenticator-iOS.git', :commit => '' + pod 'WordPressAuthenticator', '~> 3.1.0-beta.1' +# pod 'WordPressAuthenticator', :git => 'https://github.com/wordpress-mobile/WordPressAuthenticator-iOS.git', :commit => '' # pod 'WordPressAuthenticator', :git => 'https://github.com/wordpress-mobile/WordPressAuthenticator-iOS.git', :branch => '' # pod 'WordPressAuthenticator', :path => '../WordPressAuthenticator-iOS' @@ -66,7 +70,7 @@ target 'WooCommerce' do # ================== # pod 'Alamofire', '~> 4.8' - pod 'KeychainAccess', '~> 4.2.2' + keychain pod 'CocoaLumberjack', '~> 3.7.4' pod 'CocoaLumberjack/Swift', '~> 3.7.4' pod 'XLPagerTabStrip', '~> 9.0' @@ -89,6 +93,7 @@ end target 'StoreWidgetsExtension' do project 'WooCommerce/WooCommerce.xcodeproj' tracks + keychain end # Yosemite Layer: @@ -159,6 +164,10 @@ def networking_pods # Used for HTML parsing aztec + + # To allow pod to pick up beta versions use -beta. E.g., 1.1.7-beta.1 + pod 'WordPressKit', '~> 4.49.0' + # pod 'WordPressKit', :git => 'https://github.com/wordpress-mobile/WordPressKit-iOS.git', :branch => '' end # Networking Target: diff --git a/Podfile.lock b/Podfile.lock index 5a2ce26af2f..9b56119e7fa 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -6,8 +6,8 @@ PODS: - AppAuth/Core (1.5.0) - AppAuth/ExternalUserAgent (1.5.0): - AppAuth/Core - - Automattic-Tracks-iOS (0.12.0): - - Sentry (~> 6) + - Automattic-Tracks-iOS (0.12.1-beta.2): + - Sentry (~> 7.24.1) - Sodium (>= 0.9.1) - UIDeviceIdentifier (~> 2.0) - CocoaLumberjack (3.7.4): @@ -31,9 +31,9 @@ PODS: - Kingfisher (7.2.2) - NSObject-SafeExpectations (0.0.4) - "NSURL+IDN (0.4)" - - Sentry (6.2.1): - - Sentry/Core (= 6.2.1) - - Sentry/Core (6.2.1) + - Sentry (7.24.1): + - Sentry/Core (= 7.24.1) + - Sentry/Core (7.24.1) - Sodium (0.9.1) - Sourcery (1.0.3) - StripeTerminal (2.7.0) @@ -42,7 +42,7 @@ PODS: - WordPress-Aztec-iOS (1.11.0) - WordPress-Editor-iOS (1.11.0): - WordPress-Aztec-iOS (= 1.11.0) - - WordPressAuthenticator (2.4.0): + - WordPressAuthenticator (3.1.0-beta.1): - Alamofire (~> 4.8) - CocoaLumberjack (~> 3.5) - GoogleSignIn (~> 6.0.1) @@ -83,7 +83,7 @@ PODS: DEPENDENCIES: - Alamofire (~> 4.8) - - Automattic-Tracks-iOS (~> 0.12.0) + - Automattic-Tracks-iOS (~> 0.12.1-beta.2) - CocoaLumberjack (~> 3.7.4) - CocoaLumberjack/Swift (~> 3.7.4) - Gridicons (~> 1.2.0) @@ -92,7 +92,7 @@ DEPENDENCIES: - Sourcery (~> 1.0.3) - StripeTerminal (~> 2.7) - WordPress-Editor-iOS (~> 1.11.0) - - WordPressAuthenticator (~> 2.4.0) + - WordPressAuthenticator (~> 3.1.0-beta.1) - WordPressKit (~> 4.49.0) - WordPressShared (~> 1.15) - WordPressUI (~> 1.12.5) @@ -144,7 +144,7 @@ SPEC REPOS: SPEC CHECKSUMS: Alamofire: 3ec537f71edc9804815215393ae2b1a8ea33a844 AppAuth: 80317d99ac7ff2801a2f18ff86b48cd315ed465d - Automattic-Tracks-iOS: dae8787ffc2c74493a3a908abc48aed527686131 + Automattic-Tracks-iOS: 5833147f99e8fbcfc3a2c3f340d7cccd9471dc8e CocoaLumberjack: 543c79c114dadc3b1aba95641d8738b06b05b646 FormatterKit: 184db51bf120b633693a73624a4cede89ec51a41 GoogleSignIn: fd381840dbe7c1137aa6dc30849a5c3e070c034a @@ -155,7 +155,7 @@ SPEC CHECKSUMS: Kingfisher: 184d4d1a8c36666e663caf8e08abe87898595c53 NSObject-SafeExpectations: ab8fe623d36b25aa1f150affa324e40a2f3c0374 "NSURL+IDN": afc873e639c18138a1589697c3add197fe8679ca - Sentry: 9b922b396b0e0bca8516a10e36b0ea3ebea5faf7 + Sentry: 1ed2d3f2973658bf6ab7ed43857c8e321a1625dd Sodium: 23d11554ecd556196d313cf6130d406dfe7ac6da Sourcery: 70a6048014bd4f37ea80e6bd4354d47bf3b760e1 StripeTerminal: 237b759168a00c7f55b97c743cd8a4921866c46b @@ -163,7 +163,7 @@ SPEC CHECKSUMS: UIDeviceIdentifier: af4e11e25a2ea670078e2bd677bb0e8144f9f063 WordPress-Aztec-iOS: 050b34d4c3adfb7c60363849049b13d60683b348 WordPress-Editor-iOS: 304098424f1051cb271546c99f906aac296b1b81 - WordPressAuthenticator: afc1b7ec564f2b8e71c3d2ab8afa29df70ed6d03 + WordPressAuthenticator: 7adf1ab6dea0b1e01c519feb5cd2fc160f47798e WordPressKit: 96deb6ba37ea5eaec4ddcaa53eca04d653246152 WordPressShared: 5477f179c7fe03b5d574f91adda66f67d131827e WordPressUI: c5be816f6c7b3392224ac21de9e521e89fa108ac @@ -179,6 +179,6 @@ SPEC CHECKSUMS: ZendeskSupportProvidersSDK: 2bdf8544f7cd0fd4c002546f5704b813845beb2a ZendeskSupportSDK: 3a8e508ab1d9dd22dc038df6c694466414e037ba -PODFILE CHECKSUM: 79417db449eae85f8a39f1707df162e137f11d43 +PODFILE CHECKSUM: 0b413ca49ed0793630b6cffce7d1dabbaf0d2886 COCOAPODS: 1.11.2 diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 1add335b31f..d5f35192c72 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -1,8 +1,20 @@ *** PLEASE FOLLOW THIS FORMAT: [] [] -10.3 +10.4 ----- +- [*] Help center: Added help center web page with FAQs for "Pick a WooCommerce Store" screen. [https://github.com/woocommerce/woocommerce-ios/pull/7641] +- [*] In-Person Payments: Fixed a bug where cancelling a card reader connection would temporarily prevent further connections [https://github.com/woocommerce/woocommerce-ios/pull/7689] +- [*] In-Person Payments: Improvements to the card reader connection flow UI [https://github.com/woocommerce/woocommerce-ios/pull/7687] +- [*] Login: Users can now set up the Jetpack connection between a self-hosted site and their WP.com account. [https://github.com/woocommerce/woocommerce-ios/pull/7608] +10.3 +----- +- [*] Dashboard: the last selected time range tab (Today/This Week/This Month/This Year) is persisted for the site and shown on the next site launch (app launch or switching stores). [https://github.com/woocommerce/woocommerce-ios/pull/7638] +- [*] Dashboard: swiping to another time range tab now triggers syncing for the target tab. Previously, the stats on the target tab aren't synced from the swipe gesture. [https://github.com/woocommerce/woocommerce-ios/pull/7650] +- [*] In-Person Payments: Fixed an issue where the Pay in Person toggle could be out of sync with the setting on the website. [https://github.com/woocommerce/woocommerce-ios/pull/7656] +- [*] In-Person Payments: Removed the need to sign in when purchasing a card reader [https://github.com/woocommerce/woocommerce-ios/pull/7670] +- [*] In-Person Payments: Fixed a bug where canceling a reader connection could result in being unable to connect a reader in future [https://github.com/woocommerce/woocommerce-ios/pull/7678] +- [*] In-Person Payments: Fixed a bug which prevented the Collect Payment button from being shown for Cash on Delivery orders [https://github.com/woocommerce/woocommerce-ios/pull/7694] 10.2 ----- diff --git a/Scripts/build-phases/LintAppLocalizedStringsUsage.sh b/Scripts/build-phases/LintAppLocalizedStringsUsage.sh new file mode 100755 index 00000000000..9d72f087645 --- /dev/null +++ b/Scripts/build-phases/LintAppLocalizedStringsUsage.sh @@ -0,0 +1,21 @@ +#!/bin/bash -eu + +SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")" +SCRIPT_SRC="${SCRIPT_DIR}/LintAppLocalizedStringsUsage.swift" + +LINTER_BUILD_DIR="${BUILD_DIR:-${TMPDIR}}" +LINTER_EXEC="${LINTER_BUILD_DIR}/$(basename "${SCRIPT_SRC}" .swift)" + +if [ ! -x "${LINTER_EXEC}" ] || ! (shasum -c "${LINTER_EXEC}.shasum" >/dev/null 2>/dev/null); then + echo "Pre-compiling linter script to ${LINTER_EXEC}..." + swiftc -O -sdk "$(xcrun --sdk macosx --show-sdk-path)" "${SCRIPT_SRC}" -o "${LINTER_EXEC}" + shasum "${SCRIPT_SRC}" >"${LINTER_EXEC}.shasum" + chmod +x "${LINTER_EXEC}" + echo "Pre-compiled linter script ready" +fi + +if [ -z "${PROJECT_FILE_PATH:=${1:-}}" ]; then + echo "error: Please provide the path to the xcodeproj to scan" + exit 1 +fi +"$LINTER_EXEC" "${PROJECT_FILE_PATH}" "${@:2}" diff --git a/Scripts/build-phases/LintAppLocalizedStringsUsage.swift b/Scripts/build-phases/LintAppLocalizedStringsUsage.swift new file mode 100755 index 00000000000..d372a8f11a7 --- /dev/null +++ b/Scripts/build-phases/LintAppLocalizedStringsUsage.swift @@ -0,0 +1,328 @@ +import Foundation + +// MARK: Xcodeproj entry point type + +/// The main entry point type to parse `.xcodeproj` files +class Xcodeproj { + let projectURL: URL // points to the "/.xcodeproj/project.pbxproj" file + private let pbxproj: PBXProjFile + + /// Semantic type for strings that correspond to an object' UUID in the `pbxproj` file + typealias ObjectUUID = String + + /// Builds an `Xcodeproj` instance by parsing the `.xcodeproj` or `.pbxproj` file at the provided URL. + init(url: URL) throws { + projectURL = url.pathExtension == "xcodeproj" ? URL(fileURLWithPath: "project.pbxproj", relativeTo: url) : url + let data = try Data(contentsOf: projectURL) + let decoder = PropertyListDecoder() + pbxproj = try decoder.decode(PBXProjFile.self, from: data) + } + + /// An internal mapping listing the parent ObjectUUID for each ObjectUUID. + /// - Built by recursing top-to-bottom in the various `PBXGroup` objects of the project to visit all the children objects, + /// and storing which parent object they belong to. + /// - Used by the `resolveURL` method to find the real path of a `PBXReference`, as we need to navigate from the `PBXReference` object + /// up into the chain of parent `PBXGroup` containers to construct the successive relative paths of groups using `sourceTree = ""` + private lazy var referrers: [ObjectUUID: ObjectUUID] = { + var referrers: [ObjectUUID: ObjectUUID] = [:] + func recurseIfGroup(objectID: ObjectUUID) { + guard let group = try? (self.pbxproj.object(id: objectID) as PBXGroup) else { return } + for childID in group.children { + referrers[childID] = objectID + recurseIfGroup(objectID: childID) + } + } + recurseIfGroup(objectID: self.pbxproj.rootProject.mainGroup) + return referrers + }() +} + +// Convenience methods and properties +extension Xcodeproj { + /// Builds an `Xcodeproj` instance by parsing the `.xcodeproj` or `pbxproj` file at the provided path + convenience init(path: String) throws { + try self.init(url: URL(fileURLWithPath: path)) + } + + /// The directory where the `.xcodeproj` resides. + var projectDirectory: URL { projectURL.deletingLastPathComponent().deletingLastPathComponent() } + /// The list of `PBXNativeTarget` targets in the project. Convenience getter for `PBXProjFile.nativeTargets` + var nativeTargets: [PBXNativeTarget] { pbxproj.nativeTargets } + /// The list of `PBXBuildFile` files a given `PBXNativeTarget` will build. Convenience getter for `PBXProjFile.buildFiles(for:)` + func buildFiles(for target: PBXNativeTarget) -> [PBXBuildFile] { pbxproj.buildFiles(for: target) } + + /// Finds the full path / URL of a `PBXBuildFile` based on the groups it belongs to and their `sourceTree` attribute + func resolveURL(to buildFile: PBXBuildFile) throws -> URL? { + if let fileRefID = buildFile.fileRef, let fileRefObject = try? self.pbxproj.object(id: fileRefID) as PBXFileReference { + return try resolveURL(objectUUID: fileRefID, object: fileRefObject) + } else { + // If the `PBXBuildFile` is pointing to `XCVersionGroup` (like `*.xcdatamodel`) and `PBXVariantGroup` (like `*.strings`) + // (instead of a `PBXFileReference`), then in practice each file in the group's `children` will be built by the Build Phase. + // In practice we can skip parsing those in our case and save some CPU, as we don't have a need to lint those non-source-code files. + return nil // just skip those (but don't throw — those are valid use cases in any pbxproj, just ones we don't care about) + } + } + + /// Finds the full path / URL of a PBXReference (`PBXFileReference` of `PBXGroup`) based on the groups it belongs to and their `sourceTree` attribute + private func resolveURL(objectUUID: ObjectUUID, object: T) throws -> URL? { + if objectUUID == self.pbxproj.rootProject.mainGroup { return URL(fileURLWithPath: ".", relativeTo: projectDirectory) } + + switch object.sourceTree { + case .absolute: + guard let path = object.path else { throw ProjectInconsistencyError.incorrectAbsolutePath(id: objectUUID) } + return URL(fileURLWithPath: path) + case .group: + guard let parentUUID = referrers[objectUUID] else { throw ProjectInconsistencyError.orphanObject(id: objectUUID, object: object) } + let parentGroup = try self.pbxproj.object(id: parentUUID) as PBXGroup + guard let groupURL = try resolveURL(objectUUID: parentUUID, object: parentGroup) else { return nil } + return object.path.map { groupURL.appendingPathComponent($0) } ?? groupURL + case .projectRoot: + return object.path.map { URL(fileURLWithPath: $0, relativeTo: projectDirectory) } ?? projectDirectory + case .buildProductsDir, .devDir, .sdkDir: + print("\(self.projectURL.path): warning: Reference \(objectUUID) is relative to \(object.sourceTree.rawValue) which is not supported by the linter") + return nil + } + } +} + +// MARK: - Implementation Details + +/// "Parent" type for all the PBX... types of objects encountered in a pbxproj +protocol PBXObject: Decodable { + static var isa: String { get } +} +extension PBXObject { + static var isa: String { String(describing: self) } +} + +/// "Parent" type for PBXObjects referencing relative path information (`PBXFileReference`, `PBXGroup`) +protocol PBXReference: PBXObject { + var name: String? { get } + var path: String? { get } + var sourceTree: Xcodeproj.SourceTree { get } +} + +/// Types used to parse and decode the internals of a `*.xcodeproj/project.pbxproj` file +extension Xcodeproj { + /// An error `thrown` when an inconsistency is found while parsing the `.pbxproj` file. + enum ProjectInconsistencyError: Swift.Error, CustomStringConvertible { + case objectNotFound(id: ObjectUUID) + case unexpectedObjectType(id: ObjectUUID, expectedType: Any.Type, found: PBXObject) + case incorrectAbsolutePath(id: ObjectUUID) + case orphanObject(id: ObjectUUID, object: PBXObject) + + var description: String { + switch self { + case .objectNotFound(id: let id): + return "Unable to find object with UUID `\(id)`" + case .unexpectedObjectType(id: let id, expectedType: let expectedType, found: let found): + return "Object with UUID `\(id)` was expected to be of type \(expectedType) but found \(found) instead" + case .incorrectAbsolutePath(id: let id): + return "Object `\(id)` has `sourceTree = \(Xcodeproj.SourceTree.absolute)` but no `path`" + case .orphanObject(id: let id, object: let object): + return "Unable to find parent group of \(object) (`\(id)`) during file path resolution" + } + } + } + + /// Type used to represent and decode the root object of a `.pbxproj` file. + struct PBXProjFile: Decodable { + let rootObject: ObjectUUID + let objects: [String: PBXObjectWrapper] + + // Convenience methods + + /// Returns the `PBXObject` instance with the given `ObjectUUID`, by looking it up in the list of `objects` registered in the project. + func object(id: ObjectUUID) throws -> T { + guard let wrapped = objects[id] else { throw ProjectInconsistencyError.objectNotFound(id: id) } + guard let obj = wrapped.wrappedValue as? T else { + throw ProjectInconsistencyError.unexpectedObjectType(id: id, expectedType: T.self, found: wrapped.wrappedValue) + } + return obj + } + + /// Returns the `PBXObject` instance with the given `ObjectUUID`, by looking it up in the list of `objects` registered in the project. + func object(id: ObjectUUID) -> T? { + try? object(id: id) as T + } + + /// The `PBXProject` corresponding to the `rootObject` of the project file. + var rootProject: PBXProject { try! object(id: rootObject) } + + /// The `PBXGroup` corresponding to the main groop serving as root for the whole hierarchy of files and groups in the project. + var mainGroup: PBXGroup { try! object(id: rootProject.mainGroup) } + + /// The list of `PBXNativeTarget` targets found in the project. + var nativeTargets: [PBXNativeTarget] { rootProject.targets.compactMap(object(id:)) } + + /// The list of `PBXBuildFile` build file references included in a given target. + func buildFiles(for target: PBXNativeTarget) -> [PBXBuildFile] { + guard let sourceBuildPhase: PBXSourcesBuildPhase = target.buildPhases.lazy.compactMap(object(id:)).first else { return [] } + return sourceBuildPhase.files.compactMap(object(id:)) as [PBXBuildFile] + } + } + + /// One of the many `PBXObject` types encountered in the `.pbxproj` file format. + /// Represents the root project object. + struct PBXProject: PBXObject { + let mainGroup: ObjectUUID + let targets: [ObjectUUID] + } + + /// One of the many `PBXObject` types encountered in the `.pbxproj` file format. + /// Represents a native target (i.e. a target building an app, app extension, bundle...). + /// - note: Does not represent other types of targets like `PBXAggregateTarget`, only native ones. + struct PBXNativeTarget: PBXObject { + let name: String + let buildPhases: [ObjectUUID] + let productType: String + var knownProductType: ProductType? { ProductType(rawValue: productType) } + + enum ProductType: String, Decodable { + case app = "com.apple.product-type.application" + case appExtension = "com.apple.product-type.app-extension" + case unitTest = "com.apple.product-type.bundle.unit-test" + case uiTest = "com.apple.product-type.bundle.ui-testing" + case framework = "com.apple.product-type.framework" + } + } + + /// One of the many `PBXObject` types encountered in the `.pbxproj` file format. + /// Represents a "Compile Sources" build phase containing a list of files to compile. + /// - note: Does not represent other types of Build Phases that could exist in the project, only "Compile Sources" one + struct PBXSourcesBuildPhase: PBXObject { + let files: [ObjectUUID] + } + + /// One of the many `PBXObject` types encountered in the `.pbxproj` file format. + /// Represents a single build file in a `PBXSourcesBuildPhase` build phase. + struct PBXBuildFile: PBXObject { + let fileRef: ObjectUUID? + } + + /// This type is used to indicate what a file reference in the project is actually relative to + enum SourceTree: String, Decodable, CustomStringConvertible { + case absolute = "" + case group = "" + case projectRoot = "SOURCE_ROOT" + case buildProductsDir = "BUILT_PRODUCTS_DIR" + case devDir = "DEVELOPER_DIR" + case sdkDir = "SDKROOT" + var description: String { rawValue } + } + + /// One of the many `PBXObject` types encountered in the `.pbxproj` file format. + /// Represents a reference to a file contained in the project tree. + struct PBXFileReference: PBXReference { + let name: String? + let path: String? + let sourceTree: SourceTree + } + + /// One of the many `PBXObject` types encountered in the `.pbxproj` file format. + /// Represents a group (aka "folder") contained in the project tree. + struct PBXGroup: PBXReference { + let name: String? + let path: String? + let sourceTree: SourceTree + let children: [ObjectUUID] + } + + /// Fallback type for any unknown `PBXObject` type. + struct UnknownPBXObject: PBXObject { + let isa: String + } + + /// Wrapper helper to decode any `PBXObject` based on the value of their `isa` field + @propertyWrapper + struct PBXObjectWrapper: Decodable, CustomDebugStringConvertible { + let wrappedValue: PBXObject + + static let knownTypes: [PBXObject.Type] = [ + PBXProject.self, + PBXGroup.self, + PBXFileReference.self, + PBXNativeTarget.self, + PBXSourcesBuildPhase.self, + PBXBuildFile.self + ] + + init(from decoder: Decoder) throws { + let untypedObject = try UnknownPBXObject(from: decoder) + if let objectType = Self.knownTypes.first(where: { $0.isa == untypedObject.isa }) { + self.wrappedValue = try objectType.init(from: decoder) + } else { + self.wrappedValue = untypedObject + } + } + var debugDescription: String { String(describing: wrappedValue) } + } +} + + + +// MARK: - Lint method + +/// The outcome of running our lint logic on a file +enum LintResult { case ok, skipped, violationsFound([(line: Int, col: Int)]) } + +/// Lint a given file for usages of `NSLocalizedString` instead of `AppLocalizedString` +func lint(fileAt url: URL, targetName: String) throws -> LintResult { + guard ["m", "swift"].contains(url.pathExtension) else { return .skipped } + let content = try String(contentsOf: url) + var lineNo = 0 + var violations: [(line: Int, col: Int)] = [] + content.enumerateLines { line, _ in + lineNo += 1 + guard line.range(of: "\\s*//", options: .regularExpression) == nil else { return } // Skip commented lines + guard let range = line.range(of: "NSLocalizedString") else { return } + + // Violation found, report it + let colNo = line.distance(from: line.startIndex, to: range.lowerBound) + let message = """ +Use `AppLocalizedString` instead of `NSLocalizedString` in source files that are used in the `\(targetName)` extension target. See paNNhX-nP-p2 for more info. +""" + print("\(url.path):\(lineNo):\(colNo): error: \(message)") + violations.append((lineNo, colNo)) + } + return violations.isEmpty ? .ok : .violationsFound(violations) +} + + + +// MARK: - Main (Script Code entry point) + +// 1st arg = project path +let args = CommandLine.arguments.dropFirst() +guard let projectPath = args.first, !projectPath.isEmpty else { print("You must provide the path to the xcodeproj as first argument."); exit(1) } +do { + let project = try Xcodeproj(path: projectPath) + + // 2nd arg (optional) = name of target to lint + let targetsToLint: [Xcodeproj.PBXNativeTarget] + if let targetName = args.dropFirst().first, !targetName.isEmpty { + print("Selected target: \(targetName)") + targetsToLint = project.nativeTargets.filter { $0.name == targetName } + } else { + print("Linting all app extension targets") + targetsToLint = project.nativeTargets.filter { $0.knownProductType == .appExtension } + } + + // Lint each requested target + var violationsFound = 0 + for target in targetsToLint { + let buildFiles: [Xcodeproj.PBXBuildFile] = project.buildFiles(for: target) + print("Linting the Build Files for \(target.name):") + for buildFile in buildFiles { + guard let fileURL = try project.resolveURL(to: buildFile) else { continue } + let result = try lint(fileAt: fileURL.absoluteURL, targetName: target.name) + print(" - \(fileURL.relativePath) [\(result)]") + if case .violationsFound(let list) = result { violationsFound += list.count } + } + } + print("Done! \(violationsFound) violation(s) found.") + exit(violationsFound > 0 ? 1 : 0) +} catch let error { + print("\(projectPath): error: Error while parsing the project file \(projectPath): \(error.localizedDescription)") + exit(2) +} diff --git a/Storage/Storage/Model/Copiable/Models+Copiable.generated.swift b/Storage/Storage/Model/Copiable/Models+Copiable.generated.swift index e8cdd90fcdf..8cd2eee7508 100644 --- a/Storage/Storage/Model/Copiable/Models+Copiable.generated.swift +++ b/Storage/Storage/Model/Copiable/Models+Copiable.generated.swift @@ -61,20 +61,23 @@ extension GeneralStoreSettings { telemetryLastReportedTime: NullableCopiableProp = .copy, areSimplePaymentTaxesEnabled: CopiableProp = .copy, preferredInPersonPaymentGateway: NullableCopiableProp = .copy, - skippedCashOnDeliveryOnboardingStep: CopiableProp = .copy + skippedCashOnDeliveryOnboardingStep: CopiableProp = .copy, + lastSelectedStatsTimeRange: CopiableProp = .copy ) -> GeneralStoreSettings { let isTelemetryAvailable = isTelemetryAvailable ?? self.isTelemetryAvailable let telemetryLastReportedTime = telemetryLastReportedTime ?? self.telemetryLastReportedTime let areSimplePaymentTaxesEnabled = areSimplePaymentTaxesEnabled ?? self.areSimplePaymentTaxesEnabled let preferredInPersonPaymentGateway = preferredInPersonPaymentGateway ?? self.preferredInPersonPaymentGateway let skippedCashOnDeliveryOnboardingStep = skippedCashOnDeliveryOnboardingStep ?? self.skippedCashOnDeliveryOnboardingStep + let lastSelectedStatsTimeRange = lastSelectedStatsTimeRange ?? self.lastSelectedStatsTimeRange return GeneralStoreSettings( isTelemetryAvailable: isTelemetryAvailable, telemetryLastReportedTime: telemetryLastReportedTime, areSimplePaymentTaxesEnabled: areSimplePaymentTaxesEnabled, preferredInPersonPaymentGateway: preferredInPersonPaymentGateway, - skippedCashOnDeliveryOnboardingStep: skippedCashOnDeliveryOnboardingStep + skippedCashOnDeliveryOnboardingStep: skippedCashOnDeliveryOnboardingStep, + lastSelectedStatsTimeRange: lastSelectedStatsTimeRange ) } } diff --git a/Storage/Storage/Model/GeneralStoreSettings.swift b/Storage/Storage/Model/GeneralStoreSettings.swift index 44e79f50ae4..6cb38abc683 100644 --- a/Storage/Storage/Model/GeneralStoreSettings.swift +++ b/Storage/Storage/Model/GeneralStoreSettings.swift @@ -37,16 +37,21 @@ public struct GeneralStoreSettings: Codable, Equatable, GeneratedCopiable { /// public let skippedCashOnDeliveryOnboardingStep: Bool + /// The raw value string of `StatsTimeRangeV4` that indicates the last selected time range tab in store stats. + public var lastSelectedStatsTimeRange: String + public init(isTelemetryAvailable: Bool = false, telemetryLastReportedTime: Date? = nil, areSimplePaymentTaxesEnabled: Bool = false, preferredInPersonPaymentGateway: String? = nil, - skippedCashOnDeliveryOnboardingStep: Bool = false) { + skippedCashOnDeliveryOnboardingStep: Bool = false, + lastSelectedStatsTimeRange: String = "") { self.isTelemetryAvailable = isTelemetryAvailable self.telemetryLastReportedTime = telemetryLastReportedTime self.areSimplePaymentTaxesEnabled = areSimplePaymentTaxesEnabled self.preferredInPersonPaymentGateway = preferredInPersonPaymentGateway self.skippedCashOnDeliveryOnboardingStep = skippedCashOnDeliveryOnboardingStep + self.lastSelectedStatsTimeRange = lastSelectedStatsTimeRange } } @@ -62,6 +67,7 @@ extension GeneralStoreSettings { self.areSimplePaymentTaxesEnabled = try container.decodeIfPresent(Bool.self, forKey: .areSimplePaymentTaxesEnabled) ?? false self.preferredInPersonPaymentGateway = try container.decodeIfPresent(String.self, forKey: .preferredInPersonPaymentGateway) self.skippedCashOnDeliveryOnboardingStep = try container.decodeIfPresent(Bool.self, forKey: .skippedCashOnDeliveryOnboardingStep) ?? false + self.lastSelectedStatsTimeRange = try container.decodeIfPresent(String.self, forKey: .lastSelectedStatsTimeRange) ?? "" // Decode new properties with `decodeIfPresent` and provide a default value if necessary. } diff --git a/WooCommerce.xcworkspace/xcshareddata/swiftpm/Package.resolved b/WooCommerce.xcworkspace/xcshareddata/swiftpm/Package.resolved index c0ed255f533..549969dd8c9 100644 --- a/WooCommerce.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/WooCommerce.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "repositoryURL": "https://github.com/danielgindi/Charts", "state": { "branch": null, - "revision": "b38b8d45a8cbda9f0f2a3566778ed114f06056b7", - "version": "4.0.3" + "revision": "07b23476ad52b926be772f317d8f1d4511ee8d02", + "version": "4.1.0" } }, { @@ -51,8 +51,8 @@ "repositoryURL": "https://github.com/Automattic/ScreenObject", "state": { "branch": null, - "revision": "e27405a65672c62e5a055697eafdeaf073ea63ff", - "version": "0.2.1" + "revision": "cb38a32bbcc733ba03e307ca7bcae63f8c5de729", + "version": "0.2.2" } }, { diff --git a/WooCommerce/Classes/Analytics/WooAnalyticsEvent.swift b/WooCommerce/Classes/Analytics/WooAnalyticsEvent.swift index 6e2519de55d..dfb6eab7fed 100644 --- a/WooCommerce/Classes/Analytics/WooAnalyticsEvent.swift +++ b/WooCommerce/Classes/Analytics/WooAnalyticsEvent.swift @@ -1574,3 +1574,34 @@ extension WooAnalyticsEvent { } } } + +// MARK: - Universal Links +// +extension WooAnalyticsEvent { + enum Key: String { + case path = "path" + case url = "url" + } + + static func universalLinkOpened(with path: String) -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .universalLinkOpened, properties: [Key.path.rawValue: path]) + } + + static func universalLinkFailed(with url: URL) -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .universalLinkFailed, properties: [Key.url.rawValue: url.absoluteString]) + } +} + +// MARK: - Jetpack connection +// +extension WooAnalyticsEvent { + enum LoginJetpackConnection { + enum Key: String { + case selfHosted = "is_selfhosted_site" + } + + static func jetpackConnectionErrorShown(selfHostedSite: Bool) -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .loginJetpackConnectionErrorShown, properties: [Key.selfHosted.rawValue: selfHostedSite]) + } + } +} diff --git a/WooCommerce/Classes/Analytics/WooAnalyticsStat.swift b/WooCommerce/Classes/Analytics/WooAnalyticsStat.swift index aefd6b51e67..aa2e22b9dd1 100644 --- a/WooCommerce/Classes/Analytics/WooAnalyticsStat.swift +++ b/WooCommerce/Classes/Analytics/WooAnalyticsStat.swift @@ -676,6 +676,18 @@ public enum WooAnalyticsStat: String { // MARK: Payments Menu case pluginsNotSyncedYet = "plugins_not_synced_yet" + + // MARK: Universal Links + case universalLinkOpened = "universal_link_opened" + case universalLinkFailed = "universal_link_failed" + + // MARK: Login Jetpack Connection + case loginJetpackConnectionErrorShown = "login_jetpack_connection_error_shown" + case loginJetpackConnectButtonTapped = "login_jetpack_connect_button_tapped" + case loginJetpackConnectCompleted = "login_jetpack_connect_completed" + case loginJetpackConnectDismissed = "login_jetpack_connect_dismissed" + case loginJetpackConnectionURLFetchFailed = "login_jetpack_connection_url_fetch_failed" + case loginJetpackConnectionVerificationFailed = "login_jetpack_connection_verification_failed" } public extension WooAnalyticsStat { diff --git a/WooCommerce/Classes/Authentication/Navigation Exceptions/PluginSetupWebViewController.swift b/WooCommerce/Classes/Authentication/AuthenticatedWebViewController.swift similarity index 81% rename from WooCommerce/Classes/Authentication/Navigation Exceptions/PluginSetupWebViewController.swift rename to WooCommerce/Classes/Authentication/AuthenticatedWebViewController.swift index 7e831c66790..6c3e385496d 100644 --- a/WooCommerce/Classes/Authentication/Navigation Exceptions/PluginSetupWebViewController.swift +++ b/WooCommerce/Classes/Authentication/AuthenticatedWebViewController.swift @@ -2,11 +2,11 @@ import Combine import UIKit import WebKit -/// The web view to handle plugin setup in the login flow. +/// A web view which is authenticated for WordPress.com, when possible. /// -final class PluginSetupWebViewController: UIViewController { +final class AuthenticatedWebViewController: UIViewController { - private let viewModel: PluginSetupWebViewModel + private let viewModel: AuthenticatedWebViewModel /// Main web view private lazy var webView: WKWebView = { @@ -26,7 +26,7 @@ final class PluginSetupWebViewController: UIViewController { /// Strong reference for the subscription to update progress bar private var subscriptions: Set = [] - init(viewModel: PluginSetupWebViewModel) { + init(viewModel: AuthenticatedWebViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } @@ -51,7 +51,7 @@ final class PluginSetupWebViewController: UIViewController { } } -private extension PluginSetupWebViewController { +private extension AuthenticatedWebViewController { func configureNavigationBar() { title = viewModel.title } @@ -62,8 +62,19 @@ private extension PluginSetupWebViewController { view.leadingAnchor.constraint(equalTo: webView.leadingAnchor), view.trailingAnchor.constraint(equalTo: webView.trailingAnchor), view.safeTopAnchor.constraint(equalTo: webView.topAnchor), - view.bottomAnchor.constraint(equalTo: webView.bottomAnchor), + view.safeBottomAnchor.constraint(equalTo: webView.bottomAnchor), ]) + + extendContentUnderSafeAreas() + } + + func extendContentUnderSafeAreas() { + webView.scrollView.clipsToBounds = false + if #available(iOS 15.0, *) { + view.backgroundColor = webView.underPageBackgroundColor + } else { + view.backgroundColor = webView.backgroundColor + } } func configureProgressBar() { @@ -104,7 +115,7 @@ private extension PluginSetupWebViewController { } } -extension PluginSetupWebViewController: WKNavigationDelegate { +extension AuthenticatedWebViewController: WKNavigationDelegate { func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { guard let navigationURL = navigationAction.request.url else { return .allow diff --git a/WooCommerce/Classes/Authentication/Navigation Exceptions/PluginSetupWebViewModel.swift b/WooCommerce/Classes/Authentication/AuthenticatedWebViewModel.swift similarity index 84% rename from WooCommerce/Classes/Authentication/Navigation Exceptions/PluginSetupWebViewModel.swift rename to WooCommerce/Classes/Authentication/AuthenticatedWebViewModel.swift index 76b1abc5a23..3ea032dba2a 100644 --- a/WooCommerce/Classes/Authentication/Navigation Exceptions/PluginSetupWebViewModel.swift +++ b/WooCommerce/Classes/Authentication/AuthenticatedWebViewModel.swift @@ -2,8 +2,8 @@ import Foundation import WebKit /// Abstracts different configurations and logic for web view controllers -/// used for setting up plugins during the login flow -protocol PluginSetupWebViewModel { +/// which are authenticated for WordPress.com, where possible +protocol AuthenticatedWebViewModel { /// Title for the view var title: String { get } diff --git a/WooCommerce/Classes/Authentication/AuthenticationManager.swift b/WooCommerce/Classes/Authentication/AuthenticationManager.swift index b23e143d6d2..7c411bd4bed 100644 --- a/WooCommerce/Classes/Authentication/AuthenticationManager.swift +++ b/WooCommerce/Classes/Authentication/AuthenticationManager.swift @@ -189,11 +189,18 @@ class AuthenticationManager: Authentication { /// and returns an error view controller if not. func errorViewController(for siteURL: String, with matcher: ULAccountMatcher, + credentials: AuthenticatorCredentials? = nil, navigationController: UINavigationController, onStorePickerDismiss: @escaping () -> Void) -> UIViewController? { /// Account mismatched case guard matcher.match(originalURL: siteURL) else { + /// Account mismatch experiment iteration 1: show jetpack connection error + /// if the error happens during site credential login. + if let credentials = credentials?.wporg { + DDLogWarn("⚠️ Present Jetpack connection error for site: \(String(describing: siteURL))") + return jetpackConnectionUI(for: siteURL, with: credentials, in: navigationController) + } DDLogWarn("⚠️ Present account mismatch error for site: \(String(describing: siteURL))") return accountMismatchUI(for: siteURL, with: matcher) } @@ -318,7 +325,7 @@ extension AuthenticationManager: WordPressAuthenticatorDelegate { func troubleshootSite(_ siteInfo: WordPressComSiteInfo?, in navigationController: UINavigationController?) { ServiceLocator.analytics.track(event: .SitePicker.siteDiscovery(hasWordPress: siteInfo?.isWP ?? false, isWPCom: siteInfo?.isWPCom ?? false, - hasValidJetpack: siteInfo?.hasValidJetpack ?? false)) + hasValidJetpack: siteInfo?.isJetpackConnected ?? false)) guard let site = siteInfo, let navigationController = navigationController else { navigationController?.show(noWPUI, sender: nil) @@ -350,7 +357,11 @@ extension AuthenticationManager: WordPressAuthenticatorDelegate { let matcher = ULAccountMatcher(storageManager: storageManager) matcher.refreshStoredSites() - if let vc = errorViewController(for: siteURL, with: matcher, navigationController: navigationController, onStorePickerDismiss: onDismiss) { + if let vc = errorViewController(for: siteURL, + with: matcher, + credentials: credentials, + navigationController: navigationController, + onStorePickerDismiss: onDismiss) { loggedOutAppSettings?.setErrorLoginSiteAddress(siteURL) navigationController.show(vc, sender: nil) } else { @@ -533,7 +544,7 @@ private extension AuthenticationManager { private extension AuthenticationManager { func isJetpackInvalidForSelfHostedSite(url: String) -> Bool { if let site = currentSelfHostedSite, - site.url == url, !site.hasValidJetpack { + site.url == url, !site.isJetpackConnected { return true } return false @@ -607,8 +618,27 @@ private extension AuthenticationManager { return ULErrorViewController(viewModel: viewModel) } + /// The error screen to be displayed when the user tries to enter as site + /// whose Jetpack is not connected to their WP.com account. + /// This screen is currently displayed when user logged in with site credentials. + /// + func jetpackConnectionUI(for siteURL: String, + with credentials: WordPressOrgCredentials, + in navigationController: UINavigationController) -> UIViewController { + let viewModel = JetpackConnectionErrorViewModel(siteURL: siteURL, credentials: credentials, onJetpackSetupCompletion: { email in + return WordPressAuthenticator.showVerifyEmailForWPCom( + from: navigationController, + xmlrpc: credentials.xmlrpc, + connectedEmail: email, + siteURL: siteURL + ) + }) + return ULErrorViewController(viewModel: viewModel) + } + /// The error screen to be displayed when the user tries to enter a site /// whose Jetpack is not associated with their account. + /// This screen is currently displayed when user logged in with a WP.com account. /// func accountMismatchUI(for siteURL: String, with matcher: ULAccountMatcher) -> UIViewController { let viewModel = WrongAccountErrorViewModel(siteURL: siteURL, showsConnectedStores: matcher.hasConnectedStores) @@ -656,7 +686,7 @@ private extension AuthenticationManager { } /// Jetpack is required. Present an error if we don't detect a valid installation. - guard site.hasValidJetpack == true else { + guard site.isJetpackConnected == true else { return jetpackErrorUI(for: site.url, with: matcher, in: navigationController) } diff --git a/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewController.swift b/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewController.swift index d27d2de8208..e47c8ab4c27 100644 --- a/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewController.swift +++ b/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewController.swift @@ -289,7 +289,7 @@ private extension StorePickerViewController { } func presentHelp() { - ServiceLocator.authenticationManager.presentSupport(from: self, sourceTag: .generalLogin) + ServiceLocator.authenticationManager.presentSupport(from: self, screen: .storePicker) } } @@ -637,7 +637,6 @@ extension StorePickerViewController: UITableViewDataSource { hideActionButton() let cell = tableView.dequeueReusableCell(EmptyStoresTableViewCell.self, for: indexPath) let isRemoveAppleIDAccessButtonVisible = appleIDCredentialChecker.hasAppleUserID() - && featureFlagService.isFeatureFlagEnabled(.appleIDAccountDeletion) cell.updateRemoveAppleIDAccessButtonVisibility(isVisible: isRemoveAppleIDAccessButtonVisible) if isRemoveAppleIDAccessButtonVisible { cell.onCloseAccountButtonTapped = { [weak self] in diff --git a/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewModel.swift b/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewModel.swift index 1e9d2414703..6496cd35418 100644 --- a/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewModel.swift +++ b/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewModel.swift @@ -64,10 +64,8 @@ private extension StorePickerViewModel { func synchronizeSites(selectedSiteID: Int64?, onCompletion: @escaping (Result) -> Void) { let syncStartTime = Date() - let isJetpackConnectionPackageSupported = ServiceLocator.featureFlagService.isFeatureFlagEnabled(.jetpackConnectionPackageSupport) let action = AccountAction - .synchronizeSites(selectedSiteID: selectedSiteID, - isJetpackConnectionPackageSupported: isJetpackConnectionPackageSupported) { result in + .synchronizeSites(selectedSiteID: selectedSiteID) { result in switch result { case .success(let containsJCPSites): if containsJCPSites { diff --git a/WooCommerce/Classes/Authentication/Keychain+Entries.swift b/WooCommerce/Classes/Authentication/Keychain+Entries.swift index fbabe2ecf41..bbbef342df5 100644 --- a/WooCommerce/Classes/Authentication/Keychain+Entries.swift +++ b/WooCommerce/Classes/Authentication/Keychain+Entries.swift @@ -12,4 +12,11 @@ extension Keychain { get { self[WooConstants.anonymousIDKey] } set { self[WooConstants.anonymousIDKey] = newValue } } + + /// Auth token for the current selected store + /// + var currentAuthToken: String? { + get { self[WooConstants.authToken] } + set { self[WooConstants.authToken] = newValue } + } } diff --git a/WooCommerce/Classes/Authentication/Navigation Exceptions/JetpackConnectionErrorViewModel.swift b/WooCommerce/Classes/Authentication/Navigation Exceptions/JetpackConnectionErrorViewModel.swift new file mode 100644 index 00000000000..75081e3ba58 --- /dev/null +++ b/WooCommerce/Classes/Authentication/Navigation Exceptions/JetpackConnectionErrorViewModel.swift @@ -0,0 +1,193 @@ +import Combine +import Foundation +import Yosemite +import WordPressAuthenticator +import class Networking.WordPressOrgNetwork + +final class JetpackConnectionErrorViewModel: ULErrorViewModel { + private let siteURL: String + private var jetpackConnectionURL: URL? + private let stores: StoresManager + private let analytics: Analytics + private let isPrimaryButtonLoadingSubject = CurrentValueSubject(false) + private let jetpackSetupCompletionHandler: (String) -> Void + + init(siteURL: String, + credentials: WordPressOrgCredentials, + stores: StoresManager = ServiceLocator.stores, + analytics: Analytics = ServiceLocator.analytics, + onJetpackSetupCompletion: @escaping (String) -> Void) { + self.siteURL = siteURL + self.stores = stores + self.analytics = analytics + self.jetpackSetupCompletionHandler = onJetpackSetupCompletion + authenticate(with: credentials) + fetchJetpackConnectionURL() + } + + // MARK: - Data and configuration + + let image: UIImage = .productErrorImage + + var text: NSAttributedString { + let font: UIFont = .body + let boldFont: UIFont = font.bold + + let boldSiteAddress = NSAttributedString(string: siteURL.trimHTTPScheme(), + attributes: [.font: boldFont]) + let attributedString = NSMutableAttributedString(string: Localization.noJetpackEmail) + attributedString.replaceFirstOccurrence(of: "%@", with: boldSiteAddress) + + return attributedString + } + + let isAuxiliaryButtonHidden = true + + let auxiliaryButtonTitle = "" + + let primaryButtonTitle = Localization.primaryButtonTitle + + var isPrimaryButtonLoading: AnyPublisher { + isPrimaryButtonLoadingSubject.eraseToAnyPublisher() + } + + let secondaryButtonTitle = Localization.secondaryButtonTitle + + func viewDidLoad(_ viewController: UIViewController?) { + analytics.track(event: .LoginJetpackConnection.jetpackConnectionErrorShown(selfHostedSite: true)) + } + + func didTapPrimaryButton(in viewController: UIViewController?) { + analytics.track(.loginJetpackConnectButtonTapped) + showJetpackConnectionWebView(from: viewController) + } + + func didTapSecondaryButton(in viewController: UIViewController?) { + viewController?.navigationController?.popToRootViewController(animated: true) + } + + func didTapAuxiliaryButton(in viewController: UIViewController?) { + // no-op + } +} + +// MARK: - Private helpers +private extension JetpackConnectionErrorViewModel { + /// Presents a web view pointing to the Jetpack connection URL. + /// + func showJetpackConnectionWebView(from viewController: UIViewController?) { + guard let viewController = viewController else { + return + } + guard let url = jetpackConnectionURL else { + DDLogWarn("⚠️ No Jetpack connection URL found") + return + } + let viewModel = JetpackConnectionWebViewModel(initialURL: url, siteURL: siteURL, completion: { [weak self] in + self?.fetchJetpackUser(in: viewController) + }) + let pluginViewController = AuthenticatedWebViewController(viewModel: viewModel) + viewController.navigationController?.show(pluginViewController, sender: nil) + } + + /// Prepares `JetpackConnectionStore` to authenticate subsequent requests to WP.org API. + /// + func authenticate(with credentials: WordPressOrgCredentials) { + guard let authenticator = credentials.makeCookieNonceAuthenticator() else { + return + } + let network = WordPressOrgNetwork(authenticator: authenticator) + let action = JetpackConnectionAction.authenticate(siteURL: siteURL, network: network) + stores.dispatch(action) + } + + /// Fetches the URL for handling Jetpack connection in a web view + /// + func fetchJetpackConnectionURL() { + isPrimaryButtonLoadingSubject.send(true) + let action = JetpackConnectionAction.fetchJetpackConnectionURL { [weak self] result in + guard let self = self else { return } + self.isPrimaryButtonLoadingSubject.send(false) + switch result { + case .success(let url): + self.jetpackConnectionURL = url + case .failure(let error): + self.analytics.track(.loginJetpackConnectionURLFetchFailed, withError: error) + DDLogWarn("⚠️ Error fetching Jetpack connection URL: \(error)") + } + } + stores.dispatch(action) + } + + /// Gets the connected WP.com email address if possible, or show error otherwise. + /// + func fetchJetpackUser(in viewController: UIViewController) { + showInProgressView(in: viewController) + let action = JetpackConnectionAction.fetchJetpackUser { [weak self] result in + guard let self = self else { return } + // dismisses the in-progress view + viewController.navigationController?.dismiss(animated: true) + + switch result { + case .success(let user): + guard let emailAddress = user.wpcomUser?.email else { + DDLogWarn("⚠️ Cannot find connected WPcom user") + self.analytics.track(.loginJetpackConnectionVerificationFailed) + return self.showSetupErrorNotice(in: viewController) + } + self.jetpackSetupCompletionHandler(emailAddress) + case .failure(let error): + DDLogWarn("⚠️ Error fetching Jetpack user: \(error)") + self.analytics.track(.loginJetpackConnectionVerificationFailed, withError: error) + self.showSetupErrorNotice(in: viewController) + } + } + stores.dispatch(action) + } + + func showInProgressView(in viewController: UIViewController) { + let viewProperties = InProgressViewProperties(title: Localization.inProgressMessage, message: "") + let inProgressViewController = InProgressViewController(viewProperties: viewProperties) + inProgressViewController.modalPresentationStyle = .overCurrentContext + + viewController.navigationController?.present(inProgressViewController, animated: true, completion: nil) + } + + func showSetupErrorNotice(in viewController: UIViewController) { + let message = Localization.setupErrorMessage + let notice = Notice(title: message, feedbackType: .error) + let noticePresenter = DefaultNoticePresenter() + noticePresenter.presentingViewController = viewController + noticePresenter.enqueue(notice: notice) + } +} + +private extension JetpackConnectionErrorViewModel { + enum Localization { + static let noJetpackEmail = NSLocalizedString( + "It looks like your account is not connected to %@'s Jetpack", + comment: "Message explaining that the entered site credentials belong to an account that is not connected to the site's Jetpack. " + + "Reads like 'It looks like your account is not connected to awebsite.com's Jetpack") + + static let primaryButtonTitle = NSLocalizedString( + "Connect Jetpack to your account", + comment: "Button linking to web view for setting up Jetpack connection. " + + "Presented when logging in with store credentials of an account not connected to the site's Jetpack") + + static let secondaryButtonTitle = NSLocalizedString( + "Log In With Another Account", + comment: "Action button that will restart the login flow." + + "Presented when logging in with store credentials of an account not connected to the site's Jetpack" + ) + + static let inProgressMessage = NSLocalizedString( + "Verifying Jetpack connection...", + comment: "Message displayed when checking whether Jetpack has been connected successfully" + ) + + static let setupErrorMessage = NSLocalizedString( + "Cannot verify your Jetpack connection. Please try again.", + comment: "Error message displayed when failed to check for Jetpack connection." + ) + } +} diff --git a/WooCommerce/Classes/Authentication/Navigation Exceptions/JetpackConnectionWebViewModel.swift b/WooCommerce/Classes/Authentication/Navigation Exceptions/JetpackConnectionWebViewModel.swift new file mode 100644 index 00000000000..4151ac729b3 --- /dev/null +++ b/WooCommerce/Classes/Authentication/Navigation Exceptions/JetpackConnectionWebViewModel.swift @@ -0,0 +1,62 @@ +import Foundation +import WebKit + +/// View model used for the web view controller to setup Jetpack connection during the login flow. +/// +final class JetpackConnectionWebViewModel: AuthenticatedWebViewModel { + let title = Localization.title + + let initialURL: URL? + let siteURL: String + let completionHandler: () -> Void + + private let analytics: Analytics + + init(initialURL: URL, + siteURL: String, + analytics: Analytics = ServiceLocator.analytics, + completion: @escaping () -> Void) { + self.analytics = analytics + self.initialURL = initialURL + self.siteURL = siteURL + self.completionHandler = completion + } + + func handleDismissal() { + analytics.track(.loginJetpackConnectDismissed) + } + + func handleRedirect(for url: URL?) { + // No-op + } + + func decidePolicy(for navigationURL: URL) async -> WKNavigationActionPolicy { + let url = navigationURL.absoluteString + switch url { + // When the web view navigates to the site address or Jetpack plans page, + // we can assume that the setup has completed. + case let url where url.hasPrefix(siteURL) || url.hasPrefix(Constants.plansPage): + await MainActor.run { [weak self] in + self?.handleSetupCompletion() + } + return .cancel + default: + return .allow + } + } + + private func handleSetupCompletion() { + analytics.track(.loginJetpackConnectCompleted) + completionHandler() + } +} + +private extension JetpackConnectionWebViewModel { + enum Constants { + static let plansPage = "https://wordpress.com/jetpack/connect/plans" + } + + enum Localization { + static let title = NSLocalizedString("Connect Jetpack", comment: "Title of the Jetpack connection web view in the login flow") + } +} diff --git a/WooCommerce/Classes/Authentication/Navigation Exceptions/JetpackErrorViewModel.swift b/WooCommerce/Classes/Authentication/Navigation Exceptions/JetpackErrorViewModel.swift index d61d5bec565..e2eadf90b5a 100644 --- a/WooCommerce/Classes/Authentication/Navigation Exceptions/JetpackErrorViewModel.swift +++ b/WooCommerce/Classes/Authentication/Navigation Exceptions/JetpackErrorViewModel.swift @@ -63,7 +63,7 @@ struct JetpackErrorViewModel: ULErrorViewModel { } let viewModel = JetpackSetupWebViewModel(siteURL: siteURL, analytics: analytics, onCompletion: jetpackSetupCompletionHandler) - let connectionController = PluginSetupWebViewController(viewModel: viewModel) + let connectionController = AuthenticatedWebViewController(viewModel: viewModel) viewController.navigationController?.show(connectionController, sender: nil) } diff --git a/WooCommerce/Classes/Authentication/Navigation Exceptions/JetpackSetupWebViewModel.swift b/WooCommerce/Classes/Authentication/Navigation Exceptions/JetpackSetupWebViewModel.swift index 98d795fba8e..ec2ebde457d 100644 --- a/WooCommerce/Classes/Authentication/Navigation Exceptions/JetpackSetupWebViewModel.swift +++ b/WooCommerce/Classes/Authentication/Navigation Exceptions/JetpackSetupWebViewModel.swift @@ -3,7 +3,7 @@ import WebKit /// View model used for the web view controller to install Jetpack the plugin during the login flow. /// -final class JetpackSetupWebViewModel: PluginSetupWebViewModel { +final class JetpackSetupWebViewModel: AuthenticatedWebViewModel { /// The site URL to set up Jetpack for. private let siteURL: String diff --git a/WooCommerce/Classes/Authentication/Navigation Exceptions/NoWooErrorViewModel.swift b/WooCommerce/Classes/Authentication/Navigation Exceptions/NoWooErrorViewModel.swift index 43969b4610d..dde610dde30 100644 --- a/WooCommerce/Classes/Authentication/Navigation Exceptions/NoWooErrorViewModel.swift +++ b/WooCommerce/Classes/Authentication/Navigation Exceptions/NoWooErrorViewModel.swift @@ -70,7 +70,7 @@ final class NoWooErrorViewModel: ULErrorViewModel { }, onDismiss: { viewController.navigationController?.popViewController(animated: true) }) - let setupViewController = PluginSetupWebViewController(viewModel: viewModel) + let setupViewController = AuthenticatedWebViewController(viewModel: viewModel) viewController.navigationController?.show(setupViewController, sender: nil) } @@ -97,7 +97,7 @@ final class NoWooErrorViewModel: ULErrorViewModel { // MARK: - Private helpers private extension NoWooErrorViewModel { func handleSetupCompletion(in viewController: UIViewController, retryCount: Int = 0) { - let action = AccountAction.synchronizeSites(selectedSiteID: site.siteID, isJetpackConnectionPackageSupported: true) { [weak self] _ in + let action = AccountAction.synchronizeSites(selectedSiteID: site.siteID) { [weak self] _ in guard let self = self else { return } let matcher = ULAccountMatcher() diff --git a/WooCommerce/Classes/Authentication/Navigation Exceptions/ULErrorViewController.swift b/WooCommerce/Classes/Authentication/Navigation Exceptions/ULErrorViewController.swift index 174085bc622..783ad8d78a4 100644 --- a/WooCommerce/Classes/Authentication/Navigation Exceptions/ULErrorViewController.swift +++ b/WooCommerce/Classes/Authentication/Navigation Exceptions/ULErrorViewController.swift @@ -1,3 +1,4 @@ +import Combine import UIKit import WordPressAuthenticator import SafariServices @@ -13,7 +14,7 @@ final class ULErrorViewController: UIViewController { /// Contains a vertical stack of the image, error message, and extra info button by default. @IBOutlet private weak var contentStackView: UIStackView! - @IBOutlet private weak var primaryButton: UIButton! + @IBOutlet private weak var primaryButton: ButtonActivityIndicator! @IBOutlet private weak var secondaryButton: UIButton! @IBOutlet private weak var imageView: UIImageView! @IBOutlet private weak var errorMessage: UILabel! @@ -27,6 +28,10 @@ final class ULErrorViewController: UIViewController { @IBOutlet private weak var stackViewLeadingConstraint: NSLayoutConstraint! @IBOutlet private weak var stackViewTrailingConstraint: NSLayoutConstraint! + private var primaryButtonSubscription: AnyCancellable? + + private let viewDidAppearSubject = PassthroughSubject() + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { UIDevice.isPad() ? .all : .portrait } @@ -60,6 +65,11 @@ final class ULErrorViewController: UIViewController { viewModel.viewDidLoad(self) } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + viewDidAppearSubject.send() + } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) setUnifiedMargins(forWidth: view.frame.width) @@ -127,6 +137,18 @@ private extension ULErrorViewController { primaryButton.on(.touchUpInside) { [weak self] _ in self?.didTapPrimaryButton() } + + // We need to wait until view did appear to make sure the indicator stays at the correct position + primaryButtonSubscription = viewModel.isPrimaryButtonLoading.combineLatest(viewDidAppearSubject.prefix(1)) + .sink { [weak self] (isLoading, _) in + guard let self = self else { return } + self.primaryButton.isEnabled = !isLoading + if isLoading { + self.primaryButton.showActivityIndicator() + } else { + self.primaryButton.hideActivityIndicator() + } + } } func configureSecondaryButton() { diff --git a/WooCommerce/Classes/Authentication/Navigation Exceptions/ULErrorViewController.xib b/WooCommerce/Classes/Authentication/Navigation Exceptions/ULErrorViewController.xib index 19c666b9475..b1d9b8b2e33 100644 --- a/WooCommerce/Classes/Authentication/Navigation Exceptions/ULErrorViewController.xib +++ b/WooCommerce/Classes/Authentication/Navigation Exceptions/ULErrorViewController.xib @@ -74,7 +74,7 @@ -