From 860ec2e265e6c9a598e6dd9067db49eaabf11f06 Mon Sep 17 00:00:00 2001 From: Dominik Kapusta Date: Fri, 19 Jul 2024 08:43:52 +0200 Subject: [PATCH 01/24] Add support for skipping sending usage pixels for remote messages (#3106) Task/Issue URL: https://app.asana.com/0/1201621708115095/1207841204698435/f Description: This change allows to skip sending usage pixels for a given remote message if that's stated in the RMF config. --- DuckDuckGo.xcodeproj/project.pbxproj | 6 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- DuckDuckGo/Debug.storyboard | 64 +++++++--- DuckDuckGo/HistoryDebugViewController.swift | 6 +- DuckDuckGo/HomeMessageView.swift | 5 + DuckDuckGo/HomeMessageViewModel.swift | 1 + DuckDuckGo/HomeMessageViewModelBuilder.swift | 8 +- .../HomeMessageViewSectionRenderer.swift | 26 ++-- DuckDuckGo/HomePageConfiguration.swift | 12 +- DuckDuckGo/NewTabPageMessagesModel.swift | 26 ++-- DuckDuckGo/NewTabPageView.swift | 3 +- .../RemoteMessagingDebugViewController.swift | 113 ++++++++++++++++++ .../NewTabPageMessagesModelTests.swift | 69 ++++++++++- 13 files changed, 292 insertions(+), 51 deletions(-) create mode 100644 DuckDuckGo/RemoteMessagingDebugViewController.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index bdc66796da..a4dd4fccca 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -188,6 +188,7 @@ 377D80222AB48554002AF251 /* FavoritesDisplayModeSyncHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377D80212AB48554002AF251 /* FavoritesDisplayModeSyncHandler.swift */; }; 379E877429E97C8D001C8BB0 /* BookmarksCleanupErrorHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379E877329E97C8D001C8BB0 /* BookmarksCleanupErrorHandling.swift */; }; 37A6A8FE2AFD0208008580A3 /* FaviconsFetcherOnboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A6A8FD2AFD0208008580A3 /* FaviconsFetcherOnboarding.swift */; }; + 37C696772C4957940073E131 /* RemoteMessagingDebugViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C696762C4957940073E131 /* RemoteMessagingDebugViewController.swift */; }; 37CBCA9E2A8A659C0050218F /* SyncSettingsAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CBCA9D2A8A659C0050218F /* SyncSettingsAdapter.swift */; }; 37CEFCAC2A673B90001EF741 /* CredentialsCleanupErrorHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEFCAB2A673B90001EF741 /* CredentialsCleanupErrorHandling.swift */; }; 37CF91602BB4737300BADCAE /* CrashCollectionOnboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CF915F2BB4737300BADCAE /* CrashCollectionOnboarding.swift */; }; @@ -1342,6 +1343,7 @@ 377D80212AB48554002AF251 /* FavoritesDisplayModeSyncHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesDisplayModeSyncHandler.swift; sourceTree = ""; }; 379E877329E97C8D001C8BB0 /* BookmarksCleanupErrorHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksCleanupErrorHandling.swift; sourceTree = ""; }; 37A6A8FD2AFD0208008580A3 /* FaviconsFetcherOnboarding.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FaviconsFetcherOnboarding.swift; sourceTree = ""; }; + 37C696762C4957940073E131 /* RemoteMessagingDebugViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMessagingDebugViewController.swift; sourceTree = ""; }; 37CBCA9D2A8A659C0050218F /* SyncSettingsAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncSettingsAdapter.swift; sourceTree = ""; }; 37CEFCAB2A673B90001EF741 /* CredentialsCleanupErrorHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialsCleanupErrorHandling.swift; sourceTree = ""; }; 37CF915F2BB4737300BADCAE /* CrashCollectionOnboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashCollectionOnboarding.swift; sourceTree = ""; }; @@ -3976,6 +3978,7 @@ CBFCB30D2B2CD47800253E9E /* ConfigurationURLDebugViewController.swift */, 851624C62B96389D002D5CD7 /* HistoryDebugViewController.swift */, 98A860EE2C4682E00077FE4D /* BookmarksDebugViewController.swift */, + 37C696762C4957940073E131 /* RemoteMessagingDebugViewController.swift */, ); name = Debug; sourceTree = ""; @@ -7146,6 +7149,7 @@ 6FB1FEA22C256ACD0075B68B /* NewTabPageManager.swift in Sources */, 9865DFF922A8220D00D27829 /* FavoritesOverlay.swift in Sources */, 1E4DCF4627B6A33600961E25 /* DownloadsListViewModel.swift in Sources */, + 37C696772C4957940073E131 /* RemoteMessagingDebugViewController.swift in Sources */, F4F6DFB626E6B71300ED7E12 /* BookmarkFoldersTableViewController.swift in Sources */, 8586A11024CCCD040049720E /* TabsBarViewController.swift in Sources */, F1D796F41E7C2A410019D451 /* BookmarksDelegate.swift in Sources */, @@ -10166,7 +10170,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 171.2.3; + version = 172.0.0; }; }; 9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e22a6b3a86..aea2579038 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DuckDuckGo/BrowserServicesKit", "state" : { - "revision" : "278f486e71131ee8e36df4180518b0f74843d47e", - "version" : "171.2.3" + "revision" : "f8e381771a33287e317da84d5676a5e2a271bca3", + "version" : "172.0.0" } }, { diff --git a/DuckDuckGo/Debug.storyboard b/DuckDuckGo/Debug.storyboard index 2ae12b72f9..46d581edff 100644 --- a/DuckDuckGo/Debug.storyboard +++ b/DuckDuckGo/Debug.storyboard @@ -257,9 +257,21 @@ - + + + + + + + + + + + + + @@ -267,7 +279,7 @@ - + @@ -276,7 +288,7 @@ - + @@ -285,7 +297,7 @@ - + @@ -294,7 +306,7 @@ - + @@ -303,7 +315,7 @@ - + @@ -312,7 +324,7 @@ - + @@ -321,7 +333,7 @@ - + @@ -330,7 +342,7 @@ - + @@ -339,7 +351,7 @@ - + @@ -386,6 +398,22 @@ + + + + + + + + + + + + + + + + @@ -895,34 +923,34 @@ - + - + - + - + diff --git a/DuckDuckGo/HistoryDebugViewController.swift b/DuckDuckGo/HistoryDebugViewController.swift index 3d516818c3..a92a5b1dac 100644 --- a/DuckDuckGo/HistoryDebugViewController.swift +++ b/DuckDuckGo/HistoryDebugViewController.swift @@ -49,10 +49,8 @@ struct HistoryDebugRootView: View { } .navigationTitle("\(model.history.count) History Items") .toolbar { - if #available(iOS 15, *) { - Button("Delete All", role: .destructive) { - model.deleteAll() - } + Button("Delete All", role: .destructive) { + model.deleteAll() } } } diff --git a/DuckDuckGo/HomeMessageView.swift b/DuckDuckGo/HomeMessageView.swift index 3eacf7696d..5c6834306e 100644 --- a/DuckDuckGo/HomeMessageView.swift +++ b/DuckDuckGo/HomeMessageView.swift @@ -327,22 +327,27 @@ struct HomeMessageView_Previews: PreviewProvider { static var previews: some View { Group { HomeMessageView(viewModel: HomeMessageViewModel(messageId: "Small", + sendPixels: false, modelType: small, onDidClose: { _ in }, onDidAppear: {})) HomeMessageView(viewModel: HomeMessageViewModel(messageId: "Critical", + sendPixels: false, modelType: critical, onDidClose: { _ in }, onDidAppear: {})) HomeMessageView(viewModel: HomeMessageViewModel(messageId: "Big Single", + sendPixels: false, modelType: bigSingle, onDidClose: { _ in }, onDidAppear: {})) HomeMessageView(viewModel: HomeMessageViewModel(messageId: "Big Two", + sendPixels: false, modelType: bigTwo, onDidClose: { _ in }, onDidAppear: {})) HomeMessageView(viewModel: HomeMessageViewModel(messageId: "Promo", + sendPixels: false, modelType: promo, onDidClose: { _ in }, onDidAppear: {})) } diff --git a/DuckDuckGo/HomeMessageViewModel.swift b/DuckDuckGo/HomeMessageViewModel.swift index e6c7bbdd0c..26ddd24d83 100644 --- a/DuckDuckGo/HomeMessageViewModel.swift +++ b/DuckDuckGo/HomeMessageViewModel.swift @@ -31,6 +31,7 @@ struct HomeMessageViewModel { } let messageId: String + let sendPixels: Bool let modelType: RemoteMessageModelType var image: String? { diff --git a/DuckDuckGo/HomeMessageViewModelBuilder.swift b/DuckDuckGo/HomeMessageViewModelBuilder.swift index 641be47a10..42dd27f6b0 100644 --- a/DuckDuckGo/HomeMessageViewModelBuilder.swift +++ b/DuckDuckGo/HomeMessageViewModelBuilder.swift @@ -37,7 +37,13 @@ struct HomeMessageViewModelBuilder { onDidAppear: @escaping () -> Void) -> HomeMessageViewModel? { guard let content = remoteMessage.content else { return nil } - return HomeMessageViewModel(messageId: remoteMessage.id, modelType: content, onDidClose: onDidClose, onDidAppear: onDidAppear) + return HomeMessageViewModel( + messageId: remoteMessage.id, + sendPixels: remoteMessage.isMetricsEnabled, + modelType: content, + onDidClose: onDidClose, + onDidAppear: onDidAppear + ) } } diff --git a/DuckDuckGo/HomeMessageViewSectionRenderer.swift b/DuckDuckGo/HomeMessageViewSectionRenderer.swift index 8355f2466a..e039c10062 100644 --- a/DuckDuckGo/HomeMessageViewSectionRenderer.swift +++ b/DuckDuckGo/HomeMessageViewSectionRenderer.swift @@ -109,7 +109,7 @@ class HomeMessageViewSectionRenderer: NSObject, HomeViewSectionRenderer { let message = homePageConfiguration.homeMessages[indexPath.row] switch message { case .placeholder: - return HomeMessageViewModel(messageId: "", modelType: .small(titleText: "", descriptionText: "")) { [weak self] _ in + return HomeMessageViewModel(messageId: "", sendPixels: false, modelType: .small(titleText: "", descriptionText: "")) { [weak self] _ in self?.dismissHomeMessage(message, at: indexPath, in: collectionView) } onDidAppear: { // no-op @@ -126,27 +126,35 @@ class HomeMessageViewSectionRenderer: NSObject, HomeViewSectionRenderer { if !isSharing { self.dismissHomeMessage(message, at: indexPath, in: collectionView) } - Pixel.fire(pixel: .remoteMessageActionClicked, - withAdditionalParameters: [PixelParameters.message: "\(remoteMessage.id)"]) + if remoteMessage.isMetricsEnabled { + Pixel.fire(pixel: .remoteMessageActionClicked, + withAdditionalParameters: [PixelParameters.message: "\(remoteMessage.id)"]) + } case .primaryAction(let isSharing): if !isSharing { self.dismissHomeMessage(message, at: indexPath, in: collectionView) } - Pixel.fire(pixel: .remoteMessagePrimaryActionClicked, - withAdditionalParameters: [PixelParameters.message: "\(remoteMessage.id)"]) + if remoteMessage.isMetricsEnabled { + Pixel.fire(pixel: .remoteMessagePrimaryActionClicked, + withAdditionalParameters: [PixelParameters.message: "\(remoteMessage.id)"]) + } case .secondaryAction(let isSharing): if !isSharing { self.dismissHomeMessage(message, at: indexPath, in: collectionView) } - Pixel.fire(pixel: .remoteMessageSecondaryActionClicked, - withAdditionalParameters: [PixelParameters.message: "\(remoteMessage.id)"]) + if remoteMessage.isMetricsEnabled { + Pixel.fire(pixel: .remoteMessageSecondaryActionClicked, + withAdditionalParameters: [PixelParameters.message: "\(remoteMessage.id)"]) + } case .close: self.dismissHomeMessage(message, at: indexPath, in: collectionView) - Pixel.fire(pixel: .remoteMessageDismissed, - withAdditionalParameters: [PixelParameters.message: "\(remoteMessage.id)"]) + if remoteMessage.isMetricsEnabled { + Pixel.fire(pixel: .remoteMessageDismissed, + withAdditionalParameters: [PixelParameters.message: "\(remoteMessage.id)"]) + } } } onDidAppear: { [weak self] in diff --git a/DuckDuckGo/HomePageConfiguration.swift b/DuckDuckGo/HomePageConfiguration.swift index 14aa2c37d1..ed9da38ae2 100644 --- a/DuckDuckGo/HomePageConfiguration.swift +++ b/DuckDuckGo/HomePageConfiguration.swift @@ -98,13 +98,17 @@ final class HomePageConfiguration: HomePageMessagesConfiguration { switch homeMessage { case .remoteMessage(let remoteMessage): os_log("Remote message shown: %s", log: .remoteMessaging, type: .info, remoteMessage.id) - Pixel.fire(pixel: .remoteMessageShown, - withAdditionalParameters: [PixelParameters.message: "\(remoteMessage.id)"]) + if remoteMessage.isMetricsEnabled { + Pixel.fire(pixel: .remoteMessageShown, + withAdditionalParameters: [PixelParameters.message: "\(remoteMessage.id)"]) + } if !remoteMessagingClient.store.hasShownRemoteMessage(withID: remoteMessage.id) { os_log("Remote message shown for first time: %s", log: .remoteMessaging, type: .info, remoteMessage.id) - Pixel.fire(pixel: .remoteMessageShownUnique, - withAdditionalParameters: [PixelParameters.message: "\(remoteMessage.id)"]) + if remoteMessage.isMetricsEnabled { + Pixel.fire(pixel: .remoteMessageShownUnique, + withAdditionalParameters: [PixelParameters.message: "\(remoteMessage.id)"]) + } remoteMessagingClient.store.updateRemoteMessage(withID: remoteMessage.id, asShown: true) } diff --git a/DuckDuckGo/NewTabPageMessagesModel.swift b/DuckDuckGo/NewTabPageMessagesModel.swift index 486790bd58..996d6b7ea2 100644 --- a/DuckDuckGo/NewTabPageMessagesModel.swift +++ b/DuckDuckGo/NewTabPageMessagesModel.swift @@ -73,7 +73,7 @@ final class NewTabPageMessagesModel: ObservableObject { private func homeMessageViewModel(for message: HomeMessage) -> HomeMessageViewModel? { switch message { case .placeholder: - return HomeMessageViewModel(messageId: "", modelType: .small(titleText: "", descriptionText: "")) { [weak self] _ in + return HomeMessageViewModel(messageId: "", sendPixels: false, modelType: .small(titleText: "", descriptionText: "")) { [weak self] _ in self?.dismissHomeMessage(message) } onDidAppear: { // no-op @@ -90,27 +90,35 @@ final class NewTabPageMessagesModel: ObservableObject { if !isSharing { self.dismissHomeMessage(message) } - pixelFiring.fire(.remoteMessageActionClicked, - withAdditionalParameters: [PixelParameters.message: "\(remoteMessage.id)"]) + if remoteMessage.isMetricsEnabled { + pixelFiring.fire(.remoteMessageActionClicked, + withAdditionalParameters: [PixelParameters.message: "\(remoteMessage.id)"]) + } case .primaryAction(let isSharing): if !isSharing { self.dismissHomeMessage(message) } - pixelFiring.fire(.remoteMessagePrimaryActionClicked, - withAdditionalParameters: [PixelParameters.message: "\(remoteMessage.id)"]) + if remoteMessage.isMetricsEnabled { + pixelFiring.fire(.remoteMessagePrimaryActionClicked, + withAdditionalParameters: [PixelParameters.message: "\(remoteMessage.id)"]) + } case .secondaryAction(let isSharing): if !isSharing { self.dismissHomeMessage(message) } - pixelFiring.fire(.remoteMessageSecondaryActionClicked, - withAdditionalParameters: [PixelParameters.message: "\(remoteMessage.id)"]) + if remoteMessage.isMetricsEnabled { + pixelFiring.fire(.remoteMessageSecondaryActionClicked, + withAdditionalParameters: [PixelParameters.message: "\(remoteMessage.id)"]) + } case .close: self.dismissHomeMessage(message) - pixelFiring.fire(.remoteMessageDismissed, - withAdditionalParameters: [PixelParameters.message: "\(remoteMessage.id)"]) + if remoteMessage.isMetricsEnabled { + pixelFiring.fire(.remoteMessageDismissed, + withAdditionalParameters: [PixelParameters.message: "\(remoteMessage.id)"]) + } } } onDidAppear: { [weak self] in diff --git a/DuckDuckGo/NewTabPageView.swift b/DuckDuckGo/NewTabPageView.swift index 95298a90cd..6baf14af6a 100644 --- a/DuckDuckGo/NewTabPageView.swift +++ b/DuckDuckGo/NewTabPageView.swift @@ -111,7 +111,8 @@ private struct Constant { id: "0", content: .small(titleText: "Title", descriptionText: "Description"), matchingRules: [], - exclusionRules: [] + exclusionRules: [], + isMetricsEnabled: false ) ) ] diff --git a/DuckDuckGo/RemoteMessagingDebugViewController.swift b/DuckDuckGo/RemoteMessagingDebugViewController.swift new file mode 100644 index 0000000000..5527132af4 --- /dev/null +++ b/DuckDuckGo/RemoteMessagingDebugViewController.swift @@ -0,0 +1,113 @@ +// +// RemoteMessagingDebugViewController.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit +import SwiftUI +import RemoteMessaging +import Core +import Combine +import Persistence + +class RemoteMessagingDebugViewController: UIHostingController { + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder, rootView: RemoteMessagingDebugRootView()) + } + +} + +struct RemoteMessagingDebugRootView: View { + + @ObservedObject var model = RemoteMessagingDebugViewModel() + + var body: some View { + List { + Section { + ForEach(model.messages, id: \.id) { entry in + VStack(alignment: .leading) { + Text(entry.id ?? "") + .font(.system(size: 14)) + Text(entry.message ?? "") + .font(.system(size: 12)) + Text("Shown: \(String(describing: entry.shown))") + .font(.system(size: 10)) + Text("Status: \(statusString(for: entry.status))") + .font(.system(size: 10)) + } + } + } footer: { + Text("This list contains messages that have been shown plus at most 1 message that is scheduled for showing. There may be more messages in the config that will be presented, but they haven't been processed yet.") + } + } + .navigationTitle("\(model.messages.count) Remote Messages") + .toolbar { + Button("Delete All", role: .destructive) { + model.deleteAll() + } + } + } + + /// This should be kept in sync with `RemoteMessageStatus` private enum from BSK + private func statusString(for status: NSNumber?) -> String { + switch status?.int16Value { + case 0: + return "scheduled" + case 1: + return "dismissed" + case 2: + return "done" + default: + return "unknown" + } + } +} + +class RemoteMessagingDebugViewModel: ObservableObject { + + @Published var messages: [RemoteMessageManagedObject] = [] + + let database: CoreDataDatabase + + init() { + database = Database.shared + fetchMessages() + } + + func deleteAll() { + let context = database.makeContext(concurrencyType: .mainQueueConcurrencyType) + context.deleteAll(entityDescriptions: [ + RemoteMessageManagedObject.entity(in: context), + RemoteMessagingConfigManagedObject.entity(in: context) + ]) + + do { + try context.save() + } catch { + assertionFailure("Failed to save after delete all") + } + fetchMessages() + } + + func fetchMessages() { + let context = database.makeContext(concurrencyType: .mainQueueConcurrencyType) + let fetchRequest = RemoteMessageManagedObject.fetchRequest() + fetchRequest.returnsObjectsAsFaults = false + messages = (try? context.fetch(fetchRequest)) ?? [] + } +} diff --git a/DuckDuckGoTests/NewTabPageMessagesModelTests.swift b/DuckDuckGoTests/NewTabPageMessagesModelTests.swift index 093ce16c68..f35d848f6a 100644 --- a/DuckDuckGoTests/NewTabPageMessagesModelTests.swift +++ b/DuckDuckGoTests/NewTabPageMessagesModelTests.swift @@ -186,6 +186,63 @@ final class NewTabPageMessagesModelTests: XCTestCase { XCTAssertEqual(PixelFiringMock.lastParams, [PixelParameters.message: "foo"]) } + func testDoesNotFirePixelOnCloseWhenMetricsAreDisabled() throws { + let sut = createSUT() + messagesConfiguration.homeMessages = [ + .mockRemote(withType: .small(titleText: "", descriptionText: ""), isMetricsEnabled: false), + ] + sut.load() + + let model = try XCTUnwrap(sut.homeMessageViewModels.first) + + model.onDidClose(.close) + + XCTAssertNil(PixelFiringMock.lastPixel) + XCTAssertNil(PixelFiringMock.lastParams) + } + + func testDoesNotFirePixelOnActionWhenMetricsAreDisabled() throws { + let sut = createSUT() + messagesConfiguration.homeMessages = [ + .mockRemote(withType: .small(titleText: "", descriptionText: ""), isMetricsEnabled: false), + ] + sut.load() + + let model = try XCTUnwrap(sut.homeMessageViewModels.first) + model.onDidClose(.action(isShare: false)) + + XCTAssertNil(PixelFiringMock.lastPixel) + XCTAssertNil(PixelFiringMock.lastParams) + } + + func testDoesNotFirePixelOnPrimaryActionWhenMetricsAreDisabled() throws { + let sut = createSUT() + messagesConfiguration.homeMessages = [ + .mockRemote(withType: .small(titleText: "", descriptionText: ""), isMetricsEnabled: false), + ] + sut.load() + + let model = try XCTUnwrap(sut.homeMessageViewModels.first) + model.onDidClose(.primaryAction(isShare: false)) + + XCTAssertNil(PixelFiringMock.lastPixel) + XCTAssertNil(PixelFiringMock.lastParams) + } + + func testDoesNotFirePixelOnSecondaryActionWhenMetricsAreDisabled() throws { + let sut = createSUT() + messagesConfiguration.homeMessages = [ + .mockRemote(withType: .small(titleText: "", descriptionText: ""), isMetricsEnabled: false), + ] + sut.load() + + let model = try XCTUnwrap(sut.homeMessageViewModels.first) + model.onDidClose(.secondaryAction(isShare: false)) + + XCTAssertNil(PixelFiringMock.lastPixel) + XCTAssertNil(PixelFiringMock.lastParams) + } + private func createSUT() -> NewTabPageMessagesModel { NewTabPageMessagesModel(homePageMessagesConfiguration: messagesConfiguration, notificationCenter: notificationCenter, @@ -217,7 +274,15 @@ private class HomePageMessagesConfigurationMock: HomePageMessagesConfiguration { } private extension HomeMessage { - static func mockRemote(withType type: RemoteMessageModelType) -> Self { - HomeMessage.remoteMessage(remoteMessage: .init(id: "foo", content: type, matchingRules: [], exclusionRules: [])) + static func mockRemote(withType type: RemoteMessageModelType, isMetricsEnabled: Bool = true) -> Self { + HomeMessage.remoteMessage( + remoteMessage: .init( + id: "foo", + content: type, + matchingRules: [], + exclusionRules: [], + isMetricsEnabled: isMetricsEnabled + ) + ) } } From 56d955f189142637fd0aa8918b90ef4d93144d64 Mon Sep 17 00:00:00 2001 From: Christopher Brind Date: Fri, 19 Jul 2024 11:58:39 +0100 Subject: [PATCH 02/24] avoid resizing webview when keyboard shows/hides (#3094) --- DuckDuckGo.xcodeproj/project.pbxproj | 4 - DuckDuckGo/AutocompleteSuggestionsModel.swift | 129 ------------------ DuckDuckGo/BrowserChromeManager.swift | 3 +- DuckDuckGo/MainView.swift | 24 ++-- DuckDuckGo/MainViewController.swift | 16 +-- DuckDuckGo/MainViewCoordinator.swift | 7 +- DuckDuckGo/TabViewController.swift | 36 ++++- 7 files changed, 52 insertions(+), 167 deletions(-) delete mode 100644 DuckDuckGo/AutocompleteSuggestionsModel.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index a4dd4fccca..5205912fc8 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -282,7 +282,6 @@ 6FD3F8132C3EFDA200DA5797 /* FavoritesPreviewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FD3F8122C3EFDA200DA5797 /* FavoritesPreviewModel.swift */; }; 6FD3F8192C41252900DA5797 /* NewTabPageControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FD3F8182C41252900DA5797 /* NewTabPageControllerDelegate.swift */; }; 6FDA1FB32B59584400AC962A /* AddressDisplayHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FDA1FB22B59584400AC962A /* AddressDisplayHelper.swift */; }; - 6FDB3F192BD11A4400F7A307 /* AutocompleteSuggestionsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FDB3F182BD11A4400F7A307 /* AutocompleteSuggestionsModel.swift */; }; 6FE018402C25CB3F001F680D /* FavoritesSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE0183F2C25CB3F001F680D /* FavoritesSectionHeader.swift */; }; 6FE095D82BD90AFB00490FF8 /* UniversalOmniBarState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE095D72BD90AFB00490FF8 /* UniversalOmniBarState.swift */; }; 6FE127382C20492500EB5724 /* NewTabPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE127372C20492500EB5724 /* NewTabPage.swift */; }; @@ -1429,7 +1428,6 @@ 6FD3F8122C3EFDA200DA5797 /* FavoritesPreviewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesPreviewModel.swift; sourceTree = ""; }; 6FD3F8182C41252900DA5797 /* NewTabPageControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageControllerDelegate.swift; sourceTree = ""; }; 6FDA1FB22B59584400AC962A /* AddressDisplayHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressDisplayHelper.swift; sourceTree = ""; }; - 6FDB3F182BD11A4400F7A307 /* AutocompleteSuggestionsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteSuggestionsModel.swift; sourceTree = ""; }; 6FE0183F2C25CB3F001F680D /* FavoritesSectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesSectionHeader.swift; sourceTree = ""; }; 6FE095D72BD90AFB00490FF8 /* UniversalOmniBarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalOmniBarState.swift; sourceTree = ""; }; 6FE127372C20492500EB5724 /* NewTabPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPage.swift; sourceTree = ""; }; @@ -5381,7 +5379,6 @@ isa = PBXGroup; children = ( F15D431F1E706CC500BF2CDC /* AutocompleteViewController.swift */, - 6FDB3F182BD11A4400F7A307 /* AutocompleteSuggestionsModel.swift */, F17922DF1E71BB59006E3D97 /* AutocompleteViewControllerDelegate.swift */, 8562CE142B9B645C00E1D399 /* CachedBookmarkSuggestions.swift */, 851672D02BED1FC900592F24 /* AutocompleteView.swift */, @@ -6748,7 +6745,6 @@ C1BF0BA529B63D7200482B73 /* AutofillLoginPromptHelper.swift in Sources */, D664C7C92B289AA200CBFA76 /* AsyncHeadlessWebView.swift in Sources */, F1F5337C1F26A9EF00D80D4F /* UserText.swift in Sources */, - 6FDB3F192BD11A4400F7A307 /* AutocompleteSuggestionsModel.swift in Sources */, 1E8AD1C727BE9B2900ABA377 /* DownloadsListDataSource.swift in Sources */, 9FE08BDC2C2A88FA001D5EBC /* OnboardingIntroViewController.swift in Sources */, 3157B43527F497F50042D3D7 /* SaveLoginViewController.swift in Sources */, diff --git a/DuckDuckGo/AutocompleteSuggestionsModel.swift b/DuckDuckGo/AutocompleteSuggestionsModel.swift deleted file mode 100644 index cdbfe9d02f..0000000000 --- a/DuckDuckGo/AutocompleteSuggestionsModel.swift +++ /dev/null @@ -1,129 +0,0 @@ -// -// AutocompleteSuggestionsModel.swift -// DuckDuckGo -// -// Copyright © 2024 DuckDuckGo. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation -import Suggestions - -struct AutocompleteSuggestionsModel { - - private let suggestions: [Suggestion] - private let sectionedSuggestions: [[IndexedSuggestion]] - - var isEmpty: Bool { suggestions.isEmpty } - var count: Int { suggestions.count } - - var numberOfSections: Int { sectionedSuggestions.count } - - init(suggestionsResult: SuggestionResult) { - self.suggestions = suggestionsResult.all - - let sectionsForDisplay = Self.makeSectionsForDisplay(using: suggestionsResult) - sectionedSuggestions = sectionsForDisplay - } - - func indexAfter(_ index: Int) -> Int { - (index + 1 >= count) ? 0 : index + 1 - } - - func indexBefore(_ index: Int) -> Int { - (index - 1 < 0) ? count - 1 : index - 1 - } - - func numberOfRows(in section: Int) -> Int { - guard sectionedSuggestions.indices.contains(section) else { return 0 } - - return sectionedSuggestions[section].count - } - - func suggestion(for index: Int) -> Suggestion? { - guard suggestions.indices.contains(index) else { return nil } - return suggestions[index] - } - - func index(for indexPath: IndexPath) -> Int? { - indexedSuggestion(for: indexPath)?.index - } - - func suggestion(for indexPath: IndexPath) -> Suggestion? { - indexedSuggestion(for: indexPath)?.suggestion - } - - func indexPath(for itemIndex: Int) -> IndexPath? { - guard suggestions.indices.contains(itemIndex) else { return nil } - - var section: Int = 0 - var row: Int = 0 - var currentIndex = itemIndex - - while true { - let currentSectionCount = sectionedSuggestions[section].count - if currentSectionCount > currentIndex { - row = currentIndex - break - } else { - currentIndex -= currentSectionCount - section += 1 - } - } - - return IndexPath(row: row, section: section) - } - - private func indexedSuggestion(for indexPath: IndexPath) -> IndexedSuggestion? { - guard sectionedSuggestions.indices.contains(indexPath.section), - sectionedSuggestions[indexPath.section].indices.contains(indexPath.row) else { - return nil - } - - return sectionedSuggestions[indexPath.section][indexPath.row] - } -} - -private extension AutocompleteSuggestionsModel { - static func makeSectionsForDisplay(using suggestionResult: SuggestionResult) -> [[IndexedSuggestion]] { - var index = -1 - var topResults = [IndexedSuggestion]() - var remoteSuggestions = [IndexedSuggestion]() - var auxResults = [IndexedSuggestion]() - - topResults = suggestionResult.topHits.map { - index += 1 - return IndexedSuggestion(index: index, suggestion: $0) - } - - remoteSuggestions = suggestionResult.duckduckgoSuggestions.map { - index += 1 - return IndexedSuggestion(index: index, suggestion: $0) - } - - auxResults = suggestionResult.localSuggestions.map { - index += 1 - return IndexedSuggestion(index: index, suggestion: $0) - } - - let results = [topResults, remoteSuggestions, auxResults] - - return results.filter { !$0.isEmpty } - } -} - -private struct IndexedSuggestion { - let index: Int - let suggestion: Suggestion -} diff --git a/DuckDuckGo/BrowserChromeManager.swift b/DuckDuckGo/BrowserChromeManager.swift index 9ec49d3d41..d7464d23fe 100644 --- a/DuckDuckGo/BrowserChromeManager.swift +++ b/DuckDuckGo/BrowserChromeManager.swift @@ -62,7 +62,8 @@ class BrowserChromeManager: NSObject, UIScrollViewDelegate { scrollView.delegate = self - observation = scrollView.observe(\.contentSize, options: .new) { [weak self] scrollView, _ in + observation = scrollView.observe(\.contentSize, options: .new) { [weak self] scrollView, observation in + guard observation.newValue != observation.oldValue else { return } self?.scrollViewDidResizeContent(scrollView) } } diff --git a/DuckDuckGo/MainView.swift b/DuckDuckGo/MainView.swift index 00839ce9ae..87b22aa6ad 100644 --- a/DuckDuckGo/MainView.swift +++ b/DuckDuckGo/MainView.swift @@ -214,25 +214,23 @@ extension MainViewFactory { } private func constrainNavigationBarContainer() { - let navigationBarContainer = coordinator.navigationBarContainer! + let container = coordinator.navigationBarContainer! let toolbar = coordinator.toolbar! let navigationBarCollectionView = coordinator.navigationBarCollectionView! - coordinator.constraints.navigationBarContainerTop = navigationBarContainer.constrainView(superview.safeAreaLayoutGuide, by: .top) - coordinator.constraints.navigationBarContainerBottom = navigationBarContainer.constrainView(toolbar, by: .bottom, to: .top) - coordinator.constraints.navigationBarCollectionViewBottom - = navigationBarCollectionView.constrainView(navigationBarContainer, by: .bottom, relatedBy: .greaterThanOrEqual) - + coordinator.constraints.navigationBarContainerTop = container.constrainView(superview.safeAreaLayoutGuide, by: .top) + coordinator.constraints.navigationBarContainerBottom = container.constrainView(toolbar, by: .bottom, to: .top) + coordinator.constraints.navigationBarContainerHeight = container.constrainAttribute(.height, to: 52, relatedBy: .equal) + NSLayoutConstraint.activate([ coordinator.constraints.navigationBarContainerTop, - navigationBarContainer.constrainView(superview, by: .leading), - navigationBarContainer.constrainView(superview, by: .trailing), - navigationBarContainer.constrainAttribute(.height, to: 52, relatedBy: .greaterThanOrEqual), + container.constrainView(superview, by: .leading), + container.constrainView(superview, by: .trailing), + coordinator.constraints.navigationBarContainerHeight, navigationBarCollectionView.constrainAttribute(.height, to: 52), - navigationBarCollectionView.constrainView(navigationBarContainer, by: .top), - navigationBarCollectionView.constrainView(navigationBarContainer, by: .leading), - navigationBarCollectionView.constrainView(navigationBarContainer, by: .trailing), - coordinator.constraints.navigationBarCollectionViewBottom + navigationBarCollectionView.constrainView(container, by: .top), + navigationBarCollectionView.constrainView(container, by: .leading), + navigationBarCollectionView.constrainView(container, by: .trailing), ]) } diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index 796a671420..57faf7eaa6 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -157,9 +157,7 @@ class MainViewController: UIViewController { // Skip SERP flow (focusing on autocomplete logic) and prepare for new navigation when selecting search bar private var skipSERPFlow = true - - private var keyboardHeight: CGFloat = 0.0 - + var postClear: (() -> Void)? var clearInProgress = false var dataStoreWarmup: DataStoreWarmup? = DataStoreWarmup() @@ -553,15 +551,14 @@ class MainViewController: UIViewController { let animationCurveRaw = animationCurveRawNSN?.uintValue ?? UIView.AnimationOptions.curveEaseInOut.rawValue let animationCurve = UIView.AnimationOptions(rawValue: animationCurveRaw) - var height = keyboardFrame.size.height + var keyboardHeight = keyboardFrame.size.height let keyboardFrameInView = view.convert(keyboardFrame, from: nil) let safeAreaFrame = view.safeAreaLayoutGuide.layoutFrame.insetBy(dx: 0, dy: -additionalSafeAreaInsets.bottom) let intersection = safeAreaFrame.intersection(keyboardFrameInView) - height = intersection.height + keyboardHeight = intersection.height - findInPageBottomLayoutConstraint.constant = height - keyboardHeight = height + findInPageBottomLayoutConstraint.constant = keyboardHeight if let suggestionsTray = suggestionTrayController { let suggestionsFrameInView = suggestionsTray.view.convert(suggestionsTray.contentFrame, to: view) @@ -574,15 +571,14 @@ class MainViewController: UIViewController { } } - let y = self.view.frame.height - height + let y = self.view.frame.height - keyboardHeight let frame = self.findInPageView.frame UIView.animate(withDuration: duration, delay: 0, options: animationCurve, animations: { self.findInPageView.frame = CGRect(x: 0, y: y - frame.height, width: frame.width, height: frame.height) }, completion: nil) if self.appSettings.currentAddressBarPosition.isBottom { - let navBarOffset = min(0, self.toolbarHeight - intersection.height) - self.viewCoordinator.constraints.navigationBarCollectionViewBottom.constant = navBarOffset + self.viewCoordinator.constraints.navigationBarContainerHeight.constant = max(52, keyboardHeight) UIView.animate(withDuration: duration, delay: 0, options: animationCurve) { self.viewCoordinator.navigationBarContainer.superview?.layoutIfNeeded() } diff --git a/DuckDuckGo/MainViewCoordinator.swift b/DuckDuckGo/MainViewCoordinator.swift index ccd08da1f7..3004991b75 100644 --- a/DuckDuckGo/MainViewCoordinator.swift +++ b/DuckDuckGo/MainViewCoordinator.swift @@ -65,7 +65,7 @@ class MainViewCoordinator { var navigationBarContainerTop: NSLayoutConstraint! var navigationBarContainerBottom: NSLayoutConstraint! - var navigationBarCollectionViewBottom: NSLayoutConstraint! + var navigationBarContainerHeight: NSLayoutConstraint! var toolbarBottom: NSLayoutConstraint! var contentContainerTop: NSLayoutConstraint! var tabBarContainerTop: NSLayoutConstraint! @@ -137,14 +137,10 @@ class MainViewCoordinator { return } - constraints.contentContainerBottomToToolbarTop.isActive = false - constraints.contentContainerBottomToNavigationBarContainerTop.isActive = true - navigationBarContainer.isHidden = false } func setAddressBarTopActive(_ active: Bool) { - constraints.contentContainerBottomToToolbarTop.isActive = active constraints.navigationBarContainerTop.isActive = active constraints.progressBarTop.isActive = active constraints.topSlideContainerBottomToNavigationBarBottom.isActive = active @@ -152,7 +148,6 @@ class MainViewCoordinator { } func setAddressBarBottomActive(_ active: Bool) { - constraints.contentContainerBottomToNavigationBarContainerTop.isActive = active constraints.progressBarBottom.isActive = active constraints.navigationBarContainerBottom.isActive = active constraints.topSlideContainerBottomToStatusBackgroundBottom.isActive = active diff --git a/DuckDuckGo/TabViewController.swift b/DuckDuckGo/TabViewController.swift index 5e86fcf988..43d494d4e4 100644 --- a/DuckDuckGo/TabViewController.swift +++ b/DuckDuckGo/TabViewController.swift @@ -55,7 +55,8 @@ class TabViewController: UIViewController { @IBOutlet private(set) weak var errorHeader: UILabel! @IBOutlet private(set) weak var errorMessage: UILabel! @IBOutlet weak var webViewContainer: UIView! - + var webViewBottomAnchorConstraint: NSLayoutConstraint? + @IBOutlet var showBarsTapGestureRecogniser: UITapGestureRecognizer! private let instrumentation = TabInstrumentation() @@ -344,7 +345,8 @@ class TabViewController: UIViewController { addTextSizeObserver() subscribeToEmailProtectionSignOutNotification() registerForDownloadsNotifications() - + registerForAddressBarLocationNotifications() + // Setup DuckPlayer navigation handler self.youtubeNavigationHandler = YoutubePlayerNavigationHandler(duckPlayer: duckPlayer) @@ -357,7 +359,13 @@ class TabViewController: UIViewController { #endif } - + private func registerForAddressBarLocationNotifications() { + NotificationCenter.default.addObserver(self, selector: + #selector(onAddressBarPositionChanged), + name: AppUserDefaults.Notifications.addressBarPositionChanged, + object: nil) + } + @available(iOS 16.4, *) private func registerForInspectableWebViewNotifications() { NotificationCenter.default.addObserver(self, @@ -375,6 +383,16 @@ class TabViewController: UIViewController { #endif } + @objc + private func onAddressBarPositionChanged() { + updateWebViewBottomAnchor() + } + + private func updateWebViewBottomAnchor() { + let targetHeight = chromeDelegate?.barsMaxHeight ?? 0.0 + webViewBottomAnchorConstraint?.constant = appSettings.currentAddressBarPosition == .bottom ? -targetHeight : 0 + } + private func observeNetPConnectionStatusChanges() { netPConnectionObserverCancellable = netPConnectionObserver.publisher .receive(on: DispatchQueue.main) @@ -387,6 +405,7 @@ class TabViewController: UIViewController { userScripts?.autofillUserScript.emailDelegate = emailManager woShownRecently = false // don't fire if the user goes somewhere else first + updateWebViewBottomAnchor() resetNavigationBar() delegate?.tabDidRequestShowingMenuHighlighter(tab: self) tabModel.viewed = true @@ -442,7 +461,6 @@ class TabViewController: UIViewController { userContentController.delegate = self webView = WKWebView(frame: view.bounds, configuration: configuration) - webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] webView.allowsLinkPreview = true webView.allowsBackForwardNavigationGestures = true @@ -451,7 +469,17 @@ class TabViewController: UIViewController { webView.navigationDelegate = self webView.uiDelegate = self + webViewContainer.addSubview(webView) + webView.translatesAutoresizingMaskIntoConstraints = false + webViewBottomAnchorConstraint = webView.bottomAnchor.constraint(equalTo: webViewContainer.bottomAnchor) + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: webViewContainer.topAnchor), + webView.leadingAnchor.constraint(equalTo: webViewContainer.leadingAnchor), + webViewBottomAnchorConstraint!, + webView.trailingAnchor.constraint(equalTo: webViewContainer.trailingAnchor) + ]) + webView.scrollView.refreshControl = refreshControl // Be sure to set `tintColor` after the control is attached to ScrollView otherwise haptics are gone. // We don't have to care about it for this control instance the next time `setRefreshControlEnabled` From 9eb684bb2a7854752b1cda2bccb367c995558e43 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Fri, 19 Jul 2024 13:18:34 +0200 Subject: [PATCH 03/24] Fix VPN configuration removal to stop the tunnel (#3099) Task/Issue URL: https://app.asana.com/0/1207603085593419/1207832283330964/f macOS PR: https://github.com/duckduckgo/macos-browser/pull/2991 BSK PR: https://github.com/duckduckgo/BrowserServicesKit/pull/900 ## Description: Fix the VPN configuration removal handling in the extension to stop the tunnel. --- DuckDuckGo.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- .../xcshareddata/xcschemes/DuckDuckGo.xcscheme | 3 +++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 5205912fc8..1777075ca0 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -10166,7 +10166,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 172.0.0; + version = 172.0.1; }; }; 9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index aea2579038..518224f0cc 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DuckDuckGo/BrowserServicesKit", "state" : { - "revision" : "f8e381771a33287e317da84d5676a5e2a271bca3", - "version" : "172.0.0" + "revision" : "3274feb8d84fda5f27541c13f2ab428b4e77a5e2", + "version" : "172.0.1" } }, { diff --git a/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo.xcscheme b/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo.xcscheme index 093c088c63..821a6a6a5e 100644 --- a/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo.xcscheme +++ b/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/DuckDuckGo.xcscheme @@ -64,6 +64,9 @@ + + Date: Fri, 19 Jul 2024 15:18:36 +0200 Subject: [PATCH 04/24] New Tab Page Shortcuts section (#3104) --- DuckDuckGo.xcodeproj/project.pbxproj | 16 ++++ ...ofillLoginSettingsListViewController.swift | 1 + DuckDuckGo/MainViewController.swift | 27 +++++++ DuckDuckGo/NewTabPageControllerDelegate.swift | 8 ++ DuckDuckGo/NewTabPageShortcut.swift | 43 ++++++++++ DuckDuckGo/NewTabPageView.swift | 26 ++++-- DuckDuckGo/NewTabPageViewController.swift | 26 +++++- DuckDuckGo/ShortcutAccessoryView.swift | 79 +++++++++++++++++++ DuckDuckGo/ShortcutItemView.swift | 74 +++++++++++++++-- .../AI-Chat-Color-32.svg | 12 +++ .../AI-Chat-Color-32.imageset/Contents.json | 15 ++++ .../Bookmarks-Color-32.svg | 14 ++++ .../Bookmarks-Color-32.imageset/Contents.json | 15 ++++ DuckDuckGo/Shortcuts.xcassets/Contents.json | 6 ++ .../Downloads-Color-32.imageset/Contents.json | 15 ++++ .../Downloads-Color-32.svg | 5 ++ .../Contents.json | 15 ++++ .../Passwords-Autofill-Color-32.svg | 5 ++ .../Settings-Color-32.imageset/Contents.json | 15 ++++ .../Settings-Color-32.svg | 11 +++ DuckDuckGo/ShortcutsModel.swift | 46 +++++++++++ DuckDuckGo/ShortcutsView.swift | 35 +++----- DuckDuckGo/UserText.swift | 10 ++- DuckDuckGo/en.lproj/Localizable.strings | 27 ++++--- 24 files changed, 492 insertions(+), 54 deletions(-) create mode 100644 DuckDuckGo/NewTabPageShortcut.swift create mode 100644 DuckDuckGo/ShortcutAccessoryView.swift create mode 100644 DuckDuckGo/Shortcuts.xcassets/AI-Chat-Color-32.imageset/AI-Chat-Color-32.svg create mode 100644 DuckDuckGo/Shortcuts.xcassets/AI-Chat-Color-32.imageset/Contents.json create mode 100644 DuckDuckGo/Shortcuts.xcassets/Bookmarks-Color-32.imageset/Bookmarks-Color-32.svg create mode 100644 DuckDuckGo/Shortcuts.xcassets/Bookmarks-Color-32.imageset/Contents.json create mode 100644 DuckDuckGo/Shortcuts.xcassets/Contents.json create mode 100644 DuckDuckGo/Shortcuts.xcassets/Downloads-Color-32.imageset/Contents.json create mode 100644 DuckDuckGo/Shortcuts.xcassets/Downloads-Color-32.imageset/Downloads-Color-32.svg create mode 100644 DuckDuckGo/Shortcuts.xcassets/Passwords-Autofill-Color-32.imageset/Contents.json create mode 100644 DuckDuckGo/Shortcuts.xcassets/Passwords-Autofill-Color-32.imageset/Passwords-Autofill-Color-32.svg create mode 100644 DuckDuckGo/Shortcuts.xcassets/Settings-Color-32.imageset/Contents.json create mode 100644 DuckDuckGo/Shortcuts.xcassets/Settings-Color-32.imageset/Settings-Color-32.svg create mode 100644 DuckDuckGo/ShortcutsModel.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 1777075ca0..f4a7b3516b 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -261,6 +261,10 @@ 6F40D15E2C34436500BF22F0 /* HomePageDisplayDailyPixelBucketTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F40D15C2C34436200BF22F0 /* HomePageDisplayDailyPixelBucketTests.swift */; }; 6F5CC0812C2AFFE400AFC840 /* ToggleExpandButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F5CC0802C2AFFE400AFC840 /* ToggleExpandButtonView.swift */; }; 6F64AA532C47E92600CF4489 /* FavoritesFaviconLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F64AA522C47E92600CF4489 /* FavoritesFaviconLoader.swift */; }; + 6F64AA592C4818D700CF4489 /* NewTabPageShortcut.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F64AA582C4818D700CF4489 /* NewTabPageShortcut.swift */; }; + 6F64AA5B2C481AAA00CF4489 /* Shortcuts.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6F64AA5A2C481AAA00CF4489 /* Shortcuts.xcassets */; }; + 6F64AA5D2C4920D200CF4489 /* ShortcutAccessoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F64AA5C2C4920D200CF4489 /* ShortcutAccessoryView.swift */; }; + 6F64AA5F2C49463C00CF4489 /* ShortcutsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F64AA5E2C49463C00CF4489 /* ShortcutsModel.swift */; }; 6F655BE22BAB289E00AC3597 /* DefaultTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F655BE12BAB289E00AC3597 /* DefaultTheme.swift */; }; 6F8496412BC3D8EE00ADA54E /* OnboardingButtonsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F8496402BC3D8EE00ADA54E /* OnboardingButtonsView.swift */; }; 6F96FF102C2B128500162692 /* NewTabPageCustomizeButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F96FF0F2C2B128500162692 /* NewTabPageCustomizeButtonView.swift */; }; @@ -1406,6 +1410,10 @@ 6F40D15C2C34436200BF22F0 /* HomePageDisplayDailyPixelBucketTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomePageDisplayDailyPixelBucketTests.swift; sourceTree = ""; }; 6F5CC0802C2AFFE400AFC840 /* ToggleExpandButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleExpandButtonView.swift; sourceTree = ""; }; 6F64AA522C47E92600CF4489 /* FavoritesFaviconLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesFaviconLoader.swift; sourceTree = ""; }; + 6F64AA582C4818D700CF4489 /* NewTabPageShortcut.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageShortcut.swift; sourceTree = ""; }; + 6F64AA5A2C481AAA00CF4489 /* Shortcuts.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Shortcuts.xcassets; sourceTree = ""; }; + 6F64AA5C2C4920D200CF4489 /* ShortcutAccessoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutAccessoryView.swift; sourceTree = ""; }; + 6F64AA5E2C49463C00CF4489 /* ShortcutsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsModel.swift; sourceTree = ""; }; 6F655BE12BAB289E00AC3597 /* DefaultTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultTheme.swift; sourceTree = ""; }; 6F8496402BC3D8EE00ADA54E /* OnboardingButtonsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingButtonsView.swift; sourceTree = ""; }; 6F96FF0F2C2B128500162692 /* NewTabPageCustomizeButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageCustomizeButtonView.swift; sourceTree = ""; }; @@ -3569,6 +3577,10 @@ children = ( 6FE1273F2C204D9B00EB5724 /* ShortcutsView.swift */, 6FE1274A2C20943500EB5724 /* ShortcutItemView.swift */, + 6F64AA5C2C4920D200CF4489 /* ShortcutAccessoryView.swift */, + 6F64AA582C4818D700CF4489 /* NewTabPageShortcut.swift */, + 6F64AA5A2C481AAA00CF4489 /* Shortcuts.xcassets */, + 6F64AA5E2C49463C00CF4489 /* ShortcutsModel.swift */, ); name = Shortcuts; sourceTree = ""; @@ -6328,6 +6340,7 @@ F4F7F10C25813FE200045D62 /* 03_Airstream_divided_by_four.json in Resources */, AAF2E28723E0498200962AF8 /* AppIconPurple83.5x83.5@2x.png in Resources */, AA4D6AB923DE4D15007E8790 /* AppIconYellow29x29@3x.png in Resources */, + 6F64AA5B2C481AAA00CF4489 /* Shortcuts.xcassets in Resources */, 984147B424F0264B00362052 /* Authentication.storyboard in Resources */, 1EE411FD2858B9300003FE64 /* dark-trackers-2.json in Resources */, AA4D6ABC23DE4D15007E8790 /* AppIconYellow60x60@3x.png in Resources */, @@ -6673,6 +6686,7 @@ 6FE095D82BD90AFB00490FF8 /* UniversalOmniBarState.swift in Sources */, 1DEAADE82BA38AA500E25A97 /* SettingsGeneralView.swift in Sources */, 853C5F5B21BFF0AE001F7A05 /* HomeCollectionView.swift in Sources */, + 6F64AA592C4818D700CF4489 /* NewTabPageShortcut.swift in Sources */, 3132FA2627A0784600DD7A12 /* FilePreviewHelper.swift in Sources */, 9820FF502244FECC008D4782 /* UIScrollViewExtension.swift in Sources */, 8540BD5423D8D5080057FDD2 /* PreserveLoginsAlert.swift in Sources */, @@ -6897,6 +6911,7 @@ 9FB027192C26BC29009EA190 /* BrowsersComparisonModel.swift in Sources */, 3151F0EE2735800800226F58 /* VoiceSearchFeedbackView.swift in Sources */, 37CF91642BB4A82A00BADCAE /* CrashCollectionOnboardingViewModel.swift in Sources */, + 6F64AA5D2C4920D200CF4489 /* ShortcutAccessoryView.swift in Sources */, 857EEB752095FFAC008A005C /* HomeRowInstructionsViewController.swift in Sources */, D63FF8952C1B67E9006DE24D /* YoutubePlayerUserScript.swift in Sources */, 4BF3E4AF2C06A85200ED5D57 /* VPNRedditSessionWorkaround.swift in Sources */, @@ -6990,6 +7005,7 @@ 85C861E628FF1B5F00189466 /* HomeViewSectionRenderersExtension.swift in Sources */, CB825C922C071B1400BCC586 /* AlertView.swift in Sources */, 1DDF40292BA04FCD006850D9 /* SettingsPrivacyProtectionsView.swift in Sources */, + 6F64AA5F2C49463C00CF4489 /* ShortcutsModel.swift in Sources */, F1D477C61F2126CC0031ED49 /* OmniBarState.swift in Sources */, 85F2FFCD2211F615006BB258 /* MainViewController+KeyCommands.swift in Sources */, 6FD3F8192C41252900DA5797 /* NewTabPageControllerDelegate.swift in Sources */, diff --git a/DuckDuckGo/AutofillLoginSettingsListViewController.swift b/DuckDuckGo/AutofillLoginSettingsListViewController.swift index 500f9f3216..fba8246fca 100644 --- a/DuckDuckGo/AutofillLoginSettingsListViewController.swift +++ b/DuckDuckGo/AutofillLoginSettingsListViewController.swift @@ -33,6 +33,7 @@ enum AutofillSettingsSource: String { case appIconShortcut = "app_icon_shortcut" case homeScreenWidget = "home_screen_widget" case lockScreenWidget = "lock_screen_widget" + case newTabPageShortcut = "new_tab_page_shortcut" } protocol AutofillLoginSettingsListViewControllerDelegate: AnyObject { diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index 57faf7eaa6..4399ff1653 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -738,6 +738,7 @@ class MainViewController: UIViewController { homePageMessagesConfiguration: homePageConfiguration) controller.delegate = self + controller.shortcutsDelegate = self newTabPageViewController = controller addToContentContainer(controller: controller) @@ -2026,6 +2027,32 @@ extension MainViewController: NewTabPageControllerDelegate { } } +extension MainViewController: NewTabPageControllerShortcutsDelegate { + func newTabPageDidRequestDownloads(_ controller: NewTabPageViewController) { + segueToDownloads() + } + + func newTabPageDidRequestBookmarks(_ controller: NewTabPageViewController) { + segueToBookmarks() + } + + func newTabPageDidRequestPasswords(_ controller: NewTabPageViewController) { + launchAutofillLogins(source: .newTabPageShortcut) + } + + func newTabPageDidRequestAIChat(_ controller: NewTabPageViewController) { + loadUrl(Constant.duckAIURL) + } + + func newTabPageDidRequestSettings(_ controller: NewTabPageViewController) { + segueToSettings() + } + + private enum Constant { + static let duckAIURL = URL(string: "https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=1")! + } +} + extension MainViewController: TabDelegate { func tab(_ tab: TabViewController, diff --git a/DuckDuckGo/NewTabPageControllerDelegate.swift b/DuckDuckGo/NewTabPageControllerDelegate.swift index 15981c4b5d..08ecc46c65 100644 --- a/DuckDuckGo/NewTabPageControllerDelegate.swift +++ b/DuckDuckGo/NewTabPageControllerDelegate.swift @@ -25,3 +25,11 @@ protocol NewTabPageControllerDelegate: AnyObject { func newTabPageDidDeleteFavorite(_ controller: NewTabPageViewController, favorite: BookmarkEntity) func newTabPageDidEditFavorite(_ controller: NewTabPageViewController, favorite: BookmarkEntity) } + +protocol NewTabPageControllerShortcutsDelegate: AnyObject { + func newTabPageDidRequestDownloads(_ controller: NewTabPageViewController) + func newTabPageDidRequestBookmarks(_ controller: NewTabPageViewController) + func newTabPageDidRequestPasswords(_ controller: NewTabPageViewController) + func newTabPageDidRequestAIChat(_ controller: NewTabPageViewController) + func newTabPageDidRequestSettings(_ controller: NewTabPageViewController) +} diff --git a/DuckDuckGo/NewTabPageShortcut.swift b/DuckDuckGo/NewTabPageShortcut.swift new file mode 100644 index 0000000000..26674e63c9 --- /dev/null +++ b/DuckDuckGo/NewTabPageShortcut.swift @@ -0,0 +1,43 @@ +// +// NewTabPageShortcut.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit + +enum NewTabPageShortcut: CaseIterable, Equatable, Identifiable, Codable { + var id: String { storageIdentifier } + + case bookmarks, aiChat, passwords, downloads, settings +} + +extension NewTabPageShortcut { + var storageIdentifier: String { + switch self { + case .bookmarks: + "shortcut.storage.identifier.bookmarks" + case .aiChat: + "shortcut.storage.identifier.aichat" + case .passwords: + "shortcut.storage.identifier.passwords" + case .downloads: + "shortcut.storage.identifier.downloads" + case .settings: + "shortcut.storage.identifier.settings" + } + } +} diff --git a/DuckDuckGo/NewTabPageView.swift b/DuckDuckGo/NewTabPageView.swift index 6baf14af6a..0c3af6f7fe 100644 --- a/DuckDuckGo/NewTabPageView.swift +++ b/DuckDuckGo/NewTabPageView.swift @@ -24,12 +24,14 @@ import RemoteMessaging struct NewTabPageView: View { @Environment(\.horizontalSizeClass) var horizontalSizeClass - @ObservedObject var messagesModel: NewTabPageMessagesModel - @ObservedObject var favoritesModel: FavoritesModelType + @ObservedObject private var messagesModel: NewTabPageMessagesModel + @ObservedObject private var favoritesModel: FavoritesModelType + @ObservedObject private var shortcutsModel: ShortcutsModel - init(messagesModel: NewTabPageMessagesModel, favoritesModel: FavoritesModelType) { + init(messagesModel: NewTabPageMessagesModel, favoritesModel: FavoritesModelType, shortcutsModel: ShortcutsModel) { self.messagesModel = messagesModel self.favoritesModel = favoritesModel + self.shortcutsModel = shortcutsModel self.messagesModel.load() } @@ -55,8 +57,10 @@ struct NewTabPageView: View { } // MARK: Shortcuts - ShortcutsView() - .padding(Constant.sectionPadding) + if !shortcutsModel.enabledShortcuts.isEmpty { + ShortcutsView(model: shortcutsModel) + .padding(Constant.sectionPadding) + } Spacer() @@ -97,7 +101,8 @@ private struct Constant { homeMessages: [] ) ), - favoritesModel: FavoritesPreviewModel() + favoritesModel: FavoritesPreviewModel(), + shortcutsModel: ShortcutsModel() ) } @@ -118,7 +123,8 @@ private struct Constant { ] ) ), - favoritesModel: FavoritesPreviewModel() + favoritesModel: FavoritesPreviewModel(), + shortcutsModel: ShortcutsModel() ) } @@ -141,3 +147,9 @@ private final class PreviewMessagesConfiguration: HomePageMessagesConfiguration homeMessages = homeMessages.dropLast() } } + +private extension ShortcutsModel { + convenience init() { + self.init(shortcutsPreferencesStorage: InMemoryShortcutsPreferencesStorage()) + } +} diff --git a/DuckDuckGo/NewTabPageViewController.swift b/DuckDuckGo/NewTabPageViewController.swift index 6b187d8660..2d5c92fc57 100644 --- a/DuckDuckGo/NewTabPageViewController.swift +++ b/DuckDuckGo/NewTabPageViewController.swift @@ -30,6 +30,7 @@ final class NewTabPageViewController: UIHostingController + + + + + + + + + + + diff --git a/DuckDuckGo/Shortcuts.xcassets/AI-Chat-Color-32.imageset/Contents.json b/DuckDuckGo/Shortcuts.xcassets/AI-Chat-Color-32.imageset/Contents.json new file mode 100644 index 0000000000..7ccc364224 --- /dev/null +++ b/DuckDuckGo/Shortcuts.xcassets/AI-Chat-Color-32.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AI-Chat-Color-32.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/DuckDuckGo/Shortcuts.xcassets/Bookmarks-Color-32.imageset/Bookmarks-Color-32.svg b/DuckDuckGo/Shortcuts.xcassets/Bookmarks-Color-32.imageset/Bookmarks-Color-32.svg new file mode 100644 index 0000000000..1f3b554aae --- /dev/null +++ b/DuckDuckGo/Shortcuts.xcassets/Bookmarks-Color-32.imageset/Bookmarks-Color-32.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/DuckDuckGo/Shortcuts.xcassets/Bookmarks-Color-32.imageset/Contents.json b/DuckDuckGo/Shortcuts.xcassets/Bookmarks-Color-32.imageset/Contents.json new file mode 100644 index 0000000000..c2a391a6cd --- /dev/null +++ b/DuckDuckGo/Shortcuts.xcassets/Bookmarks-Color-32.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Bookmarks-Color-32.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/DuckDuckGo/Shortcuts.xcassets/Contents.json b/DuckDuckGo/Shortcuts.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/DuckDuckGo/Shortcuts.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Shortcuts.xcassets/Downloads-Color-32.imageset/Contents.json b/DuckDuckGo/Shortcuts.xcassets/Downloads-Color-32.imageset/Contents.json new file mode 100644 index 0000000000..9b57292103 --- /dev/null +++ b/DuckDuckGo/Shortcuts.xcassets/Downloads-Color-32.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Downloads-Color-32.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/DuckDuckGo/Shortcuts.xcassets/Downloads-Color-32.imageset/Downloads-Color-32.svg b/DuckDuckGo/Shortcuts.xcassets/Downloads-Color-32.imageset/Downloads-Color-32.svg new file mode 100644 index 0000000000..950c33172e --- /dev/null +++ b/DuckDuckGo/Shortcuts.xcassets/Downloads-Color-32.imageset/Downloads-Color-32.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/DuckDuckGo/Shortcuts.xcassets/Passwords-Autofill-Color-32.imageset/Contents.json b/DuckDuckGo/Shortcuts.xcassets/Passwords-Autofill-Color-32.imageset/Contents.json new file mode 100644 index 0000000000..211f5e0af8 --- /dev/null +++ b/DuckDuckGo/Shortcuts.xcassets/Passwords-Autofill-Color-32.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Passwords-Autofill-Color-32.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/DuckDuckGo/Shortcuts.xcassets/Passwords-Autofill-Color-32.imageset/Passwords-Autofill-Color-32.svg b/DuckDuckGo/Shortcuts.xcassets/Passwords-Autofill-Color-32.imageset/Passwords-Autofill-Color-32.svg new file mode 100644 index 0000000000..371a50c006 --- /dev/null +++ b/DuckDuckGo/Shortcuts.xcassets/Passwords-Autofill-Color-32.imageset/Passwords-Autofill-Color-32.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/DuckDuckGo/Shortcuts.xcassets/Settings-Color-32.imageset/Contents.json b/DuckDuckGo/Shortcuts.xcassets/Settings-Color-32.imageset/Contents.json new file mode 100644 index 0000000000..9171449b3d --- /dev/null +++ b/DuckDuckGo/Shortcuts.xcassets/Settings-Color-32.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Settings-Color-32.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/DuckDuckGo/Shortcuts.xcassets/Settings-Color-32.imageset/Settings-Color-32.svg b/DuckDuckGo/Shortcuts.xcassets/Settings-Color-32.imageset/Settings-Color-32.svg new file mode 100644 index 0000000000..07bca140f7 --- /dev/null +++ b/DuckDuckGo/Shortcuts.xcassets/Settings-Color-32.imageset/Settings-Color-32.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/DuckDuckGo/ShortcutsModel.swift b/DuckDuckGo/ShortcutsModel.swift new file mode 100644 index 0000000000..21ef76d50a --- /dev/null +++ b/DuckDuckGo/ShortcutsModel.swift @@ -0,0 +1,46 @@ +// +// ShortcutsModel.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol ShortcutsPreferencesStorage { + var enabledShortcuts: [NewTabPageShortcut] { get } +} + +final class ShortcutsModel: ObservableObject { + @Published private(set) var enabledShortcuts: [NewTabPageShortcut] = [] + + private let shortcutsPreferencesStorage: ShortcutsPreferencesStorage + + var onShortcutOpened: ((NewTabPageShortcut) -> Void)? + + init(shortcutsPreferencesStorage: ShortcutsPreferencesStorage) { + self.shortcutsPreferencesStorage = shortcutsPreferencesStorage + + enabledShortcuts = shortcutsPreferencesStorage.enabledShortcuts + } + + func openShortcut(_ shortcut: NewTabPageShortcut) { + onShortcutOpened?(shortcut) + } +} + +final class InMemoryShortcutsPreferencesStorage: ShortcutsPreferencesStorage { + private(set) var enabledShortcuts: [NewTabPageShortcut] = NewTabPageShortcut.allCases +} diff --git a/DuckDuckGo/ShortcutsView.swift b/DuckDuckGo/ShortcutsView.swift index cb3bb026d0..3ee93cf3c2 100644 --- a/DuckDuckGo/ShortcutsView.swift +++ b/DuckDuckGo/ShortcutsView.swift @@ -19,38 +19,25 @@ import SwiftUI -enum Shortcut: Int, CaseIterable, Equatable, Identifiable { - var id: Int { rawValue } - - case bookmarks, aiChat, vpn, passwords - - var name: String { - switch self { - case .bookmarks: - UserText.homeTabShortcutBookmarks - case .aiChat: - UserText.homeTabShortcutAIChat - case .vpn: - UserText.homeTabShortcutVPN - case .passwords: - UserText.homeTabShortcutPasswords - } - } -} - struct ShortcutsView: View { - @Environment(\.horizontalSizeClass) var horizontalSizeClass - @State var enabledShortcuts: [Shortcut] = Array(Shortcut.allCases.prefix(upTo: 3)) + @ObservedObject private(set) var model: ShortcutsModel var body: some View { NewTabPageGridView { _ in - ForEach(enabledShortcuts) { shortcut in - ShortcutItemView(name: shortcut.name) + ForEach(model.enabledShortcuts) { shortcut in + Button { + model.openShortcut(shortcut) + } label: { + ShortcutItemView(shortcut: shortcut, accessoryType: nil) + } } } } } #Preview { - ShortcutsView() + ScrollView { + ShortcutsView(model: ShortcutsModel(shortcutsPreferencesStorage: InMemoryShortcutsPreferencesStorage())) + } + .background(Color(designSystemColor: .background)) } diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index 0464d99144..13bd95fe9d 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -1176,10 +1176,12 @@ But if you *do* want a peek under the hood, you can find more information about public static let duckPlayerPresentationModalDismissButton = NSLocalizedString("duckplayer.presentation.modal.dismiss-button", value: "Got it!", comment: "Button that will dismiss the modal") // Home Tab Shortcuts - public static let homeTabShortcutBookmarks = NSLocalizedString("home.tab.shortcut.bookmarks", value: "Bookmarks", comment: "Shortcut title leading to Bookmarks") - public static let homeTabShortcutAIChat = NSLocalizedString("home.tab.shortcut.ai.chat", value: "AI Chat", comment: "Shortcut title leading to AI Chat") - public static let homeTabShortcutVPN = NSLocalizedString("home.tab.shortcut.vpn", value: "VPN", comment: "Shortcut title leading to VPN") - public static let homeTabShortcutPasswords = NSLocalizedString("home.tab.shortcut.passwords", value: "Passwords", comment: "Shortcut title leading to Passwords") + public static let newTabPageShortcutBookmarks = NSLocalizedString("new.tab.page.shortcut.bookmarks", value: "Bookmarks", comment: "Shortcut title leading to Bookmarks") + public static let newTabPageShortcutAIChat = NSLocalizedString("new.tab.page.shortcut.ai.chat", value: "AI Chat", comment: "Shortcut title leading to AI Chat") + public static let newTabPageShortcutPasswords = NSLocalizedString("new.tab.page.shortcut.passwords", value: "Passwords", comment: "Shortcut title leading to Passwords") + + public static let newTabPageShortcutDownloads = NSLocalizedString("new.tab.page.shortcut.downloads", value: "Downloads", comment: "Shortcut title leading to Downloads") + public static let newTabPageShortcutSettings = NSLocalizedString("new.tab.page.shortcut.settings", value: "Settings", comment: "Shortcut title leading to app settings") // MARK: - Dax Onboarding Experiment public enum DaxOnboardingExperiment { diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index e28055ff28..9e71046420 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -1249,18 +1249,6 @@ /* Home is this context is the bottom home row (dock) */ "home.row.reminder.title" = "Take DuckDuckGo home"; -/* Shortcut title leading to AI Chat */ -"home.tab.shortcut.ai.chat" = "AI Chat"; - -/* Shortcut title leading to Bookmarks */ -"home.tab.shortcut.bookmarks" = "Bookmarks"; - -/* Shortcut title leading to Passwords */ -"home.tab.shortcut.passwords" = "Passwords"; - -/* Shortcut title leading to VPN */ -"home.tab.shortcut.vpn" = "VPN"; - /* This describes empty tab */ "homeTab.searchAndFavorites" = "Search or enter address"; @@ -1576,6 +1564,21 @@ https://duckduckgo.com/mac"; /* Title for the VPN Settings screen. */ "network.protection.vpn.settings.title" = "VPN Settings"; +/* Shortcut title leading to AI Chat */ +"new.tab.page.shortcut.ai.chat" = "AI Chat"; + +/* Shortcut title leading to Bookmarks */ +"new.tab.page.shortcut.bookmarks" = "Bookmarks"; + +/* Shortcut title leading to Downloads */ +"new.tab.page.shortcut.downloads" = "Downloads"; + +/* Shortcut title leading to Passwords */ +"new.tab.page.shortcut.passwords" = "Passwords"; + +/* Shortcut title leading to app settings */ +"new.tab.page.shortcut.settings" = "Settings"; + /* Do not translate - stringsdict entry */ "number.of.tabs" = "number.of.tabs"; From 01a1c386db1b789f98143902cfc88f8168ba0d1e Mon Sep 17 00:00:00 2001 From: Daniel Bernal Date: Fri, 19 Jul 2024 18:02:02 +0200 Subject: [PATCH 05/24] [DuckPlayer] 6 - Init updates and Watch on YouTube (#3066) Task/Issue URL: https://app.asana.com/0/1201141132935289/1207777888338886/f Description: Updates DuckPlayer initialization requirements Renames Youtubplayernavhandler to DuckPlayernavhandler Adds DuckPlayer logging Opens video on Youtube when you tap "Watch in Youtube" button Opens video on Youtube player when you tap the "Youtube" logo in the player itself --- Core/Logging.swift | 5 +- Core/UserDefaultsPropertyWrapper.swift | 3 +- DuckDuckGo.xcodeproj/project.pbxproj | 8 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../DuckPlayer/DuckNavigationHandling.swift | 9 +- DuckDuckGo/DuckPlayer/DuckPlayer.swift | 55 ++++- ...wift => DuckPlayerNavigationHandler.swift} | 118 +++++++--- .../DuckPlayer/DuckPlayerURLExtension.swift | 10 +- .../DuckPlayer/YoutubeOverlayUserScript.swift | 2 +- .../DuckPlayer/YoutubePlayerUserScript.swift | 2 +- DuckDuckGo/TabManager.swift | 10 +- DuckDuckGo/TabViewController.swift | 52 ++--- ...ViewControllerLongPressMenuExtension.swift | 3 +- DuckDuckGoTests/DuckPlayerMocks.swift | 8 + .../DuckPlayerURLExtensionTests.swift | 50 +++-- ...YoutublePlayerNavigationHandlerTests.swift | 206 +++++++++--------- submodules/privacy-reference-tests | 2 +- 17 files changed, 324 insertions(+), 221 deletions(-) rename DuckDuckGo/DuckPlayer/{YouTubePlayerNavigationHandler.swift => DuckPlayerNavigationHandler.swift} (64%) diff --git a/Core/Logging.swift b/Core/Logging.swift index fd6bdac037..9be20c9153 100644 --- a/Core/Logging.swift +++ b/Core/Logging.swift @@ -31,6 +31,7 @@ public extension OSLog { case autoconsentLog = "DDG Autoconsent" case configurationLog = "DDG Configuration" case syncLog = "DDG Sync" + case duckPlayerLog = "Duck Player" } @OSLogWrapper(.generalLog) static var generalLog @@ -40,6 +41,7 @@ public extension OSLog { @OSLogWrapper(.autoconsentLog) static var autoconsentLog @OSLogWrapper(.configurationLog) static var configurationLog @OSLogWrapper(.syncLog) static var syncLog + @OSLogWrapper(.duckPlayerLog) static var duckPlayerLog // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! // To activate Logging Categories add categories here: @@ -50,7 +52,8 @@ public extension OSLog { .adAttributionLog, .lifecycleLog, .configurationLog, - .syncLog + .syncLog, + .duckPlayerLog ] #endif diff --git a/Core/UserDefaultsPropertyWrapper.swift b/Core/UserDefaultsPropertyWrapper.swift index dd3d93e456..94c8cdc6f6 100644 --- a/Core/UserDefaultsPropertyWrapper.swift +++ b/Core/UserDefaultsPropertyWrapper.swift @@ -150,9 +150,10 @@ public struct UserDefaultsWrapper { case duckPlayerMode = "com.duckduckgo.ios.duckPlayerMode" case duckPlayerAskModeOverlayHidden = "com.duckduckgo.ios.duckPlayerAskModeOverlayHidden" - + case vpnRedditWorkaroundInstalled = "com.duckduckgo.ios.vpn.workaroundInstalled" + // Debug keys case debugNewTabPageSectionsEnabledKey = "com.duckduckgo.ios.debug.newTabPageSectionsEnabled" diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index f4a7b3516b..cc96638f15 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -830,7 +830,7 @@ D64648AF2B5993890033090B /* SubscriptionEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64648AE2B5993890033090B /* SubscriptionEmailViewModel.swift */; }; D652498E2B515A6A0056B0DE /* SubscriptionSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D652498D2B515A6A0056B0DE /* SubscriptionSettingsViewModel.swift */; }; D65625902C22D307006EF297 /* DuckPlayerURLExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63FF88B2C1B21ED006DE24D /* DuckPlayerURLExtension.swift */; }; - D65625922C22D340006EF297 /* YouTubePlayerNavigationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63FF8892C1B21C2006DE24D /* YouTubePlayerNavigationHandler.swift */; }; + D65625922C22D340006EF297 /* DuckPlayerNavigationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63FF8892C1B21C2006DE24D /* DuckPlayerNavigationHandler.swift */; }; D65625952C22D382006EF297 /* TabViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F159BDA31F0BDB5A00B4A01D /* TabViewController.swift */; }; D65625A12C232F5E006EF297 /* SettingsDuckPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65625A02C232F5E006EF297 /* SettingsDuckPlayerView.swift */; }; D65CEA702B6AC6C9008A759B /* Subscription.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D65CEA6F2B6AC6C9008A759B /* Subscription.xcassets */; }; @@ -2512,7 +2512,7 @@ D62EC3C12C248AF800FC9D04 /* DuckNavigationHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckNavigationHandling.swift; sourceTree = ""; }; D63657182A7BAE7C001AF19D /* EmailManagerRequestDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmailManagerRequestDelegate.swift; sourceTree = ""; }; D63677F42BBDB1C300605BA5 /* DaxLogoNavbarTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaxLogoNavbarTitle.swift; sourceTree = ""; }; - D63FF8892C1B21C2006DE24D /* YouTubePlayerNavigationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubePlayerNavigationHandler.swift; sourceTree = ""; }; + D63FF8892C1B21C2006DE24D /* DuckPlayerNavigationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayerNavigationHandler.swift; sourceTree = ""; }; D63FF88B2C1B21ED006DE24D /* DuckPlayerURLExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayerURLExtension.swift; sourceTree = ""; }; D63FF8932C1B67E8006DE24D /* YoutubePlayerUserScript.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = YoutubePlayerUserScript.swift; sourceTree = ""; }; D63FF8942C1B67E8006DE24D /* YoutubeOverlayUserScript.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = YoutubeOverlayUserScript.swift; sourceTree = ""; }; @@ -4730,7 +4730,7 @@ D63FF8972C1B6A45006DE24D /* DuckPlayer.swift */, D6037E682C32F2E7009AAEC0 /* DuckPlayerSettings.swift */, D62EC3C12C248AF800FC9D04 /* DuckNavigationHandling.swift */, - D63FF8892C1B21C2006DE24D /* YouTubePlayerNavigationHandler.swift */, + D63FF8892C1B21C2006DE24D /* DuckPlayerNavigationHandler.swift */, D63FF88B2C1B21ED006DE24D /* DuckPlayerURLExtension.swift */, D63FF8942C1B67E8006DE24D /* YoutubeOverlayUserScript.swift */, D63FF8932C1B67E8006DE24D /* YoutubePlayerUserScript.swift */, @@ -6972,7 +6972,7 @@ 1EA51376286596A000493C6A /* PrivacyIconLogic.swift in Sources */, 980891A92238504B00313A70 /* UILabelExtension.swift in Sources */, 984D035A24ACCC7D0066CFB8 /* TabViewCell.swift in Sources */, - D65625922C22D340006EF297 /* YouTubePlayerNavigationHandler.swift in Sources */, + D65625922C22D340006EF297 /* DuckPlayerNavigationHandler.swift in Sources */, 31951E8E2823003200CAF535 /* AutofillLoginDetailsHeaderView.swift in Sources */, F194FAED1F14E2B3009B4DF8 /* UIFontExtension.swift in Sources */, 98F0FC2021FF18E700CE77AB /* AutoClearSettingsViewController.swift in Sources */, diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 518224f0cc..a8a4f9f886 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -138,7 +138,7 @@ { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser.git", + "location" : "https://github.com/apple/swift-argument-parser", "state" : { "revision" : "0fbc8848e389af3bb55c182bc19ca9d5dc2f255b", "version" : "1.4.0" diff --git a/DuckDuckGo/DuckPlayer/DuckNavigationHandling.swift b/DuckDuckGo/DuckPlayer/DuckNavigationHandling.swift index c2e2178472..33ad44fb25 100644 --- a/DuckDuckGo/DuckPlayer/DuckNavigationHandling.swift +++ b/DuckDuckGo/DuckPlayer/DuckNavigationHandling.swift @@ -21,13 +21,10 @@ import WebKit protocol DuckNavigationHandling { var referrer: DuckPlayerReferrer { get set } - func handleNavigation(_ navigationAction: WKNavigationAction, - webView: WKWebView, - completion: @escaping (WKNavigationActionPolicy) -> Void) + var duckPlayer: DuckPlayerProtocol { get } + func handleNavigation(_ navigationAction: WKNavigationAction, webView: WKWebView) func handleURLChange(url: URL?, webView: WKWebView) - func handleDecidePolicyFor(_ navigationAction: WKNavigationAction, - completion: @escaping (WKNavigationActionPolicy) -> Void, - webView: WKWebView) + func handleDecidePolicyFor(_ navigationAction: WKNavigationAction, webView: WKWebView) func handleGoBack(webView: WKWebView) func handleReload(webView: WKWebView) } diff --git a/DuckDuckGo/DuckPlayer/DuckPlayer.swift b/DuckDuckGo/DuckPlayer/DuckPlayer.swift index fb54fd00b9..df7e5ecf29 100644 --- a/DuckDuckGo/DuckPlayer/DuckPlayer.swift +++ b/DuckDuckGo/DuckPlayer/DuckPlayer.swift @@ -26,7 +26,7 @@ import UserScript import Core /// Values that the Frontend can use to determine the current state. -struct InitialSetupSettings: Codable { +struct InitialPlayerSettings: Codable { struct PlayerSettings: Codable { let pip: PIP } @@ -34,16 +34,36 @@ struct InitialSetupSettings: Codable { struct PIP: Codable { let status: Status } + + struct Platform: Codable { + let name: String + } enum Status: String, Codable { case enabled case disabled } + + enum Environment: String, Codable { + case development + case production + } + + enum Locale: String, Codable { + case en + } let userValues: UserValues let settings: PlayerSettings + let platform: Platform + let locale: Locale } +struct InitialOverlaySettings: Codable { + let userValues: UserValues +} + + /// Values that the Frontend can use to determine user settings public struct UserValues: Codable { enum CodingKeys: String, CodingKey { @@ -67,7 +87,9 @@ protocol DuckPlayerProtocol { func setUserValues(params: Any, message: WKScriptMessage) -> Encodable? func getUserValues(params: Any, message: WKScriptMessage) -> Encodable? func openVideoInDuckPlayer(url: URL, webView: WKWebView) - func initialSetup(params: Any, message: WKScriptMessage) async -> Encodable? + + func initialSetupPlayer(params: Any, message: WKScriptMessage) async -> Encodable? + func initialSetupOverlay(params: Any, message: WKScriptMessage) async -> Encodable? } final class DuckPlayer: DuckPlayerProtocol { @@ -103,9 +125,15 @@ final class DuckPlayer: DuckPlayerProtocol { } @MainActor - public func initialSetup(params: Any, message: WKScriptMessage) async -> Encodable? { + public func initialSetupPlayer(params: Any, message: WKScriptMessage) async -> Encodable? { let webView = message.webView - return await self.encodedSettings(with: webView) + return await self.encodedPlayerSettings(with: webView) + } + + @MainActor + public func initialSetupOverlay(params: Any, message: WKScriptMessage) async -> Encodable? { + let webView = message.webView + return await self.encodedPlayerSettings(with: webView) } private func encodeUserValues() -> UserValues { @@ -116,14 +144,21 @@ final class DuckPlayer: DuckPlayerProtocol { } @MainActor - private func encodedSettings(with webView: WKWebView?) async -> InitialSetupSettings { + private func encodedPlayerSettings(with webView: WKWebView?) async -> InitialPlayerSettings { let isPiPEnabled = webView?.configuration.allowsPictureInPictureMediaPlayback == true - let pip = InitialSetupSettings.PIP(status: isPiPEnabled ? .enabled : .disabled) - - let playerSettings = InitialSetupSettings.PlayerSettings(pip: pip) + let pip = InitialPlayerSettings.PIP(status: isPiPEnabled ? .enabled : .disabled) + let platform = InitialPlayerSettings.Platform(name: "ios") + let environment = InitialPlayerSettings.Environment.development + let locale = InitialPlayerSettings.Locale.en + let playerSettings = InitialPlayerSettings.PlayerSettings(pip: pip) let userValues = encodeUserValues() - - return InitialSetupSettings(userValues: userValues, settings: playerSettings) + return InitialPlayerSettings(userValues: userValues, settings: playerSettings, platform: platform, locale: locale) + } + + @MainActor + private func encodedOverlaySettings(with webView: WKWebView?) async -> InitialOverlaySettings { + let userValues = encodeUserValues() + return InitialOverlaySettings(userValues: userValues) } } diff --git a/DuckDuckGo/DuckPlayer/YouTubePlayerNavigationHandler.swift b/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift similarity index 64% rename from DuckDuckGo/DuckPlayer/YouTubePlayerNavigationHandler.swift rename to DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift index d5288d97cd..bf93b4dd01 100644 --- a/DuckDuckGo/DuckPlayer/YouTubePlayerNavigationHandler.swift +++ b/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift @@ -1,5 +1,5 @@ // -// YouTubePlayerNavigationHandler.swift +// DuckPlayerNavigationHandler.swift // DuckDuckGo // // Copyright © 2024 DuckDuckGo. All rights reserved. @@ -21,11 +21,14 @@ import Foundation import ContentScopeScripts import WebKit import Core +import Common -final class YoutubePlayerNavigationHandler { +final class DuckPlayerNavigationHandler { var duckPlayer: DuckPlayerProtocol var referrer: DuckPlayerReferrer = .other + var lastHandledVideoID: String? + var isDuckPlayerTemporarilyDisabled = false private struct Constants { static let SERPURL = "https://duckduckgo.com/" @@ -38,10 +41,13 @@ final class YoutubePlayerNavigationHandler { static let duckPlayerDefaultString = "default" static let settingsKey = "settings" static let httpMethod = "GET" + static let watchInYoutubePath = "openInYoutube" + static let watchInYoutubeVideoParameter = "v" } init(duckPlayer: DuckPlayerProtocol) { self.duckPlayer = duckPlayer + os_log("DP: Trying to load the same video while in DuckPlayer, use Youtube:", log: .duckPlayerLog, type: .debug) } static var htmlTemplatePath: String { @@ -92,14 +98,33 @@ final class YoutubePlayerNavigationHandler { } -extension YoutubePlayerNavigationHandler: DuckNavigationHandling { +extension DuckPlayerNavigationHandler: DuckNavigationHandling { // Handle rendering the simulated request if the URL is duck:// // and DuckPlayer is either enabled or alwaysAsk @MainActor - func handleNavigation(_ navigationAction: WKNavigationAction, - webView: WKWebView, - completion: @escaping (WKNavigationActionPolicy) -> Void) { + func handleNavigation(_ navigationAction: WKNavigationAction, webView: WKWebView) { + + os_log("DP: Handling DuckPlayer Player Navigation for %s", log: .duckPlayerLog, type: .debug, navigationAction.request.url?.absoluteString ?? "") + + // Handle Open in Youtube Links + // duck://player/openInYoutube?v=12345 + if let url = navigationAction.request.url, + url.scheme == "duck" { + let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) + + if urlComponents?.path == "/\(Constants.watchInYoutubePath)", + let queryItems = urlComponents?.queryItems { + + if let videoParameterItem = queryItems.first(where: { $0.name == Constants.watchInYoutubeVideoParameter }), + let id = videoParameterItem.value { + // Disable DP temporarily + isDuckPlayerTemporarilyDisabled = true + handleURLChange(url: URL.youtube(id, timestamp: nil), webView: webView) + return + } + } + } // Daily Unique View Pixel if let url = navigationAction.request.url, @@ -110,8 +135,7 @@ extension YoutubePlayerNavigationHandler: DuckNavigationHandling { } // Pixel for Views From Youtube - if let url = navigationAction.request.url, - referrer == .youtube, + if referrer == .youtube, duckPlayer.settings.mode == .enabled { Pixel.fire(pixel: Pixel.Event.duckPlayerViewFromYoutubeAutomatic, debounce: 2) } @@ -119,12 +143,12 @@ extension YoutubePlayerNavigationHandler: DuckNavigationHandling { // If DuckPlayer is Enabled or in ask mode, render the video if let url = navigationAction.request.url, url.isDuckURLScheme, - duckPlayer.settings.mode == .enabled || duckPlayer.settings.mode == .alwaysAsk { - let html = Self.makeHTMLFromTemplate() + duckPlayer.settings.mode == .enabled || duckPlayer.settings.mode == .alwaysAsk, + !isDuckPlayerTemporarilyDisabled { let newRequest = Self.makeDuckPlayerRequest(from: URLRequest(url: url)) if #available(iOS 15.0, *) { - webView.loadSimulatedRequest(newRequest, responseHTML: html) - completion(.allow) + os_log("DP: Loading Simulated Request for %s", log: .duckPlayerLog, type: .debug, navigationAction.request.url?.absoluteString ?? "") + performRequest(request: newRequest, webView: webView) return } } @@ -133,13 +157,10 @@ extension YoutubePlayerNavigationHandler: DuckNavigationHandling { if let url = navigationAction.request.url, let (videoID, timestamp) = url.youtubeVideoParams, duckPlayer.settings.mode == .disabled { - webView.load(URLRequest(url: URL.youtube(videoID, timestamp: timestamp))) - completion(.allow) + os_log("DP: is Disabled. We should load original video for %s", log: .duckPlayerLog, type: .debug) + handleURLChange(url: URL.youtube(videoID, timestamp: timestamp), webView: webView) return } - - completion(.allow) - } // Handle URL changes not triggered via Omnibar @@ -147,24 +168,59 @@ extension YoutubePlayerNavigationHandler: DuckNavigationHandling { @MainActor func handleURLChange(url: URL?, webView: WKWebView) { + // Do not handle the URL if the video was just handled + if let url = url, + url.isYoutubeVideo || url.isDuckPlayer, + let (videoID, _) = url.youtubeVideoParams, + lastHandledVideoID == videoID, + !isDuckPlayerTemporarilyDisabled { + return + } + if let url = url, url.isYoutubeVideo, !url.isDuckPlayer, let (videoID, timestamp) = url.youtubeVideoParams, - duckPlayer.settings.mode == .enabled || duckPlayer.settings.mode == .alwaysAsk { - webView.stopLoading() - let newURL = URL.duckPlayer(videoID, timestamp: timestamp) + duckPlayer.settings.mode == .enabled || duckPlayer.settings.mode == .alwaysAsk { + + os_log("DP: Handling URL change: %s", log: .duckPlayerLog, type: .debug, url.absoluteString) + var newURL = URL.duckPlayer(videoID, timestamp: timestamp) + + // IF DP is temporarily disabled, load Youtube website + // Then reset the setting + if isDuckPlayerTemporarilyDisabled { + os_log("DP: Duckplayer is temporarily disabled. Opening Youtube", log: .duckPlayerLog, type: .debug) + newURL = URL.youtube(videoID, timestamp: timestamp) + } else { + os_log("DP: Duckplayer is NOT disabled. Opening DuckPlayer", log: .duckPlayerLog, type: .debug) + } + + // Load the URL webView.load(URLRequest(url: newURL)) + + // Add a short delay to let the webview start the navigation + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.lastHandledVideoID = videoID + self.isDuckPlayerTemporarilyDisabled = false + } } - } // DecidePolicyFor handler to redirect relevant requests // to duck://player @MainActor func handleDecidePolicyFor(_ navigationAction: WKNavigationAction, - completion: @escaping (WKNavigationActionPolicy) -> Void, webView: WKWebView) { + // Do not handle the URL if the video was just handled + if let url = navigationAction.request.url, + url.isYoutubeVideo || url.isDuckPlayer, + let (videoID, _) = url.youtubeVideoParams, + lastHandledVideoID == videoID, + !isDuckPlayerTemporarilyDisabled { + return + } + + // Pixel for Views From SERP if let url = navigationAction.request.url, navigationAction.request.allHTTPHeaderFields?[Constants.refererHeader] == Constants.SERPURL, @@ -179,21 +235,20 @@ extension YoutubePlayerNavigationHandler: DuckNavigationHandling { Pixel.fire(pixel: Pixel.Event.duckPlayerViewFromOther, debounce: 2) } - if let url = navigationAction.request.url, - url.isYoutubeVideo, - !url.isDuckPlayer, let (videoID, timestamp) = url.youtubeVideoParams, - duckPlayer.settings.mode == .enabled || duckPlayer.settings.mode == .alwaysAsk { - webView.load(URLRequest(url: .duckPlayer(videoID, timestamp: timestamp))) - completion(.allow) + url.isYoutubeVideo, + !url.isDuckPlayer, let (videoID, timestamp) = url.youtubeVideoParams, + duckPlayer.settings.mode == .enabled || duckPlayer.settings.mode == .alwaysAsk { + os_log("DP: Handling decidePolicy for Duck Player with %s", log: .duckPlayerLog, type: .debug, url.absoluteString) + handleURLChange(url: URL.duckPlayer(videoID, timestamp: timestamp), webView: webView) return } - completion(.allow) } // Handle Webview BackButton on DuckPlayer videos @MainActor func handleGoBack(webView: WKWebView) { + guard let backURL = webView.backForwardList.backItem?.url, backURL.isYoutubeVideo, backURL.youtubeVideoParams?.videoID == webView.url?.youtubeVideoParams?.videoID, @@ -204,14 +259,15 @@ extension YoutubePlayerNavigationHandler: DuckNavigationHandling { webView.goBack(skippingHistoryItems: 2) } - // Handle Reload for DuckPlayer Videos @MainActor func handleReload(webView: WKWebView) { + if let url = webView.url, url.isDuckPlayer, !url.isDuckURLScheme, let (videoID, timestamp) = url.youtubeVideoParams, - duckPlayer.settings.mode == .enabled || duckPlayer.settings.mode == .alwaysAsk { + duckPlayer.settings.mode == .enabled || duckPlayer.settings.mode == .alwaysAsk { + os_log("DP: Handling DuckPlayer Reload for %s", log: .duckPlayerLog, type: .debug, url.absoluteString) webView.load(URLRequest(url: .duckPlayer(videoID, timestamp: timestamp))) } else { webView.reload() diff --git a/DuckDuckGo/DuckPlayer/DuckPlayerURLExtension.swift b/DuckDuckGo/DuckPlayer/DuckPlayerURLExtension.swift index d17a9b3780..5ae878341a 100644 --- a/DuckDuckGo/DuckPlayer/DuckPlayerURLExtension.swift +++ b/DuckDuckGo/DuckPlayer/DuckPlayerURLExtension.swift @@ -42,8 +42,14 @@ extension URL { } static func youtube(_ videoID: String, timestamp: String? = nil) -> URL { - let url = "https://www.youtube.com/watch?v=\(videoID)".url! - return url.addingTimestamp(timestamp) + #if os(iOS) + let baseUrl = "https://m.youtube.com/watch?v=\(videoID)" + #else + let baseUrl = "https://www.youtube.com/watch?v=\(videoID)" + #endif + + let url = URL(string: baseUrl)! + return url.addingTimestamp(timestamp) } var isDuckURLScheme: Bool { diff --git a/DuckDuckGo/DuckPlayer/YoutubeOverlayUserScript.swift b/DuckDuckGo/DuckPlayer/YoutubeOverlayUserScript.swift index 091afb731b..261e1d912b 100644 --- a/DuckDuckGo/DuckPlayer/YoutubeOverlayUserScript.swift +++ b/DuckDuckGo/DuckPlayer/YoutubeOverlayUserScript.swift @@ -107,7 +107,7 @@ final class YoutubeOverlayUserScript: NSObject, Subfeature { case Handlers.sendDuckPlayerPixel: return handleSendJSPixel case Handlers.initialSetup: - return duckPlayer.initialSetup + return duckPlayer.initialSetupOverlay default: assertionFailure("YoutubeOverlayUserScript: Failed to parse User Script message: \(methodName)") // TODO: Send pixel here diff --git a/DuckDuckGo/DuckPlayer/YoutubePlayerUserScript.swift b/DuckDuckGo/DuckPlayer/YoutubePlayerUserScript.swift index 2536de5f83..af283c3615 100644 --- a/DuckDuckGo/DuckPlayer/YoutubePlayerUserScript.swift +++ b/DuckDuckGo/DuckPlayer/YoutubePlayerUserScript.swift @@ -72,7 +72,7 @@ final class YoutubePlayerUserScript: NSObject, Subfeature { case Handlers.setUserValues: return duckPlayer.setUserValues case Handlers.initialSetup: - return duckPlayer.initialSetup + return duckPlayer.initialSetupPlayer default: assertionFailure("YoutubePlayerUserScript: Failed to parse User Script message: \(methodName)") return nil diff --git a/DuckDuckGo/TabManager.swift b/DuckDuckGo/TabManager.swift index d0a73362c6..12ed099455 100644 --- a/DuckDuckGo/TabManager.swift +++ b/DuckDuckGo/TabManager.swift @@ -35,6 +35,7 @@ class TabManager { private let historyManager: HistoryManaging private let syncService: DDGSyncing private var previewsSource: TabPreviewsSource + private var duckPlayerNavigationHandler: DuckNavigationHandling weak var delegate: TabDelegate? @@ -52,6 +53,9 @@ class TabManager { self.bookmarksDatabase = bookmarksDatabase self.historyManager = historyManager self.syncService = syncService + + // Init Duck Player Handler + self.duckPlayerNavigationHandler = DuckPlayerNavigationHandler(duckPlayer: DuckPlayer()) registerForNotifications() } @@ -68,7 +72,8 @@ class TabManager { let controller = TabViewController.loadFromStoryboard(model: tab, bookmarksDatabase: bookmarksDatabase, historyManager: historyManager, - syncService: syncService) + syncService: syncService, + duckPlayerNavigationHandler: duckPlayerNavigationHandler) controller.applyInheritedAttribution(inheritedAttribution) controller.attachWebView(configuration: configuration, andLoadRequest: url == nil ? nil : URLRequest.userInitiated(url!), @@ -140,7 +145,8 @@ class TabManager { let controller = TabViewController.loadFromStoryboard(model: tab, bookmarksDatabase: bookmarksDatabase, historyManager: historyManager, - syncService: syncService) + syncService: syncService, + duckPlayerNavigationHandler: duckPlayerNavigationHandler) controller.attachWebView(configuration: configCopy, andLoadRequest: request, consumeCookies: !model.hasActiveTabs, diff --git a/DuckDuckGo/TabViewController.swift b/DuckDuckGo/TabViewController.swift index 43d494d4e4..5f3aa279e8 100644 --- a/DuckDuckGo/TabViewController.swift +++ b/DuckDuckGo/TabViewController.swift @@ -294,7 +294,8 @@ class TabViewController: UIViewController { appSettings: AppSettings = AppDependencyProvider.shared.appSettings, bookmarksDatabase: CoreDataDatabase, historyManager: HistoryManaging, - syncService: DDGSyncing) -> TabViewController { + syncService: DDGSyncing, + duckPlayerNavigationHandler: DuckNavigationHandling) -> TabViewController { let storyboard = UIStoryboard(name: "Tab", bundle: nil) let controller = storyboard.instantiateViewController(identifier: "TabViewController", creator: { coder in TabViewController(coder: coder, @@ -302,7 +303,8 @@ class TabViewController: UIViewController { appSettings: appSettings, bookmarksDatabase: bookmarksDatabase, historyManager: historyManager, - syncService: syncService) + syncService: syncService, + duckPlayerNavigationHandler: duckPlayerNavigationHandler) }) return controller } @@ -313,22 +315,22 @@ class TabViewController: UIViewController { let historyManager: HistoryManaging let historyCapture: HistoryCapture - - var duckPlayer: DuckPlayerProtocol = DuckPlayer() - var youtubeNavigationHandler: DuckNavigationHandling? + var duckPlayerNavigationHandler: DuckNavigationHandling required init?(coder aDecoder: NSCoder, tabModel: Tab, appSettings: AppSettings, bookmarksDatabase: CoreDataDatabase, historyManager: HistoryManaging, - syncService: DDGSyncing) { + syncService: DDGSyncing, + duckPlayerNavigationHandler: DuckNavigationHandling) { self.tabModel = tabModel self.appSettings = appSettings self.bookmarksDatabase = bookmarksDatabase self.historyManager = historyManager self.historyCapture = HistoryCapture(historyManager: historyManager) self.syncService = syncService + self.duckPlayerNavigationHandler = duckPlayerNavigationHandler super.init(coder: aDecoder) } @@ -346,9 +348,6 @@ class TabViewController: UIViewController { subscribeToEmailProtectionSignOutNotification() registerForDownloadsNotifications() registerForAddressBarLocationNotifications() - - // Setup DuckPlayer navigation handler - self.youtubeNavigationHandler = YoutubePlayerNavigationHandler(duckPlayer: duckPlayer) if #available(iOS 16.4, *) { registerForInspectableWebViewNotifications() @@ -681,18 +680,15 @@ class TabViewController: UIViewController { } else if let currentHost = url?.host, let newHost = webView.url?.host, currentHost == newHost { url = webView.url - if let handler = youtubeNavigationHandler, - let url, + if let url, url.isYoutubeVideo, - duckPlayer.settings.mode == .enabled { - handler.handleURLChange(url: url, webView: webView) + duckPlayerNavigationHandler.duckPlayer.settings.mode == .enabled { + duckPlayerNavigationHandler.handleURLChange(url: url, webView: webView) } } - if var handler = youtubeNavigationHandler, - let url { - handler.referrer = url.isYoutube ? .youtube : .other - + if let url { + duckPlayerNavigationHandler.referrer = url.isYoutube ? .youtube : .other } } @@ -744,8 +740,8 @@ class TabViewController: UIViewController { public func reload() { updateContentMode() cachedRuntimeConfigurationForDomain = [:] - if let url = webView.url, url.isDuckPlayer, let handler = youtubeNavigationHandler { - handler.handleReload(webView: webView) + if let url = webView.url, url.isDuckPlayer { + duckPlayerNavigationHandler.handleReload(webView: webView) } else { webView.reload() } @@ -759,8 +755,8 @@ class TabViewController: UIViewController { func goBack() { dismissJSAlertIfNeeded() - if let url = url, url.isDuckPlayer, let handler = youtubeNavigationHandler { - handler.handleGoBack(webView: webView) + if let url = url, url.isDuckPlayer { + duckPlayerNavigationHandler.handleGoBack(webView: webView) chromeDelegate?.omniBar.resignFirstResponder() return } @@ -1676,10 +1672,10 @@ extension TabViewController: WKNavigationDelegate { } if navigationAction.isTargetingMainFrame(), - let handler = youtubeNavigationHandler, url.isYoutubeVideo, - duckPlayer.settings.mode == .enabled { - handler.handleDecidePolicyFor(navigationAction, completion: completion, webView: webView) + duckPlayerNavigationHandler.duckPlayer.settings.mode == .enabled { + duckPlayerNavigationHandler.handleDecidePolicyFor(navigationAction, webView: webView) + completion(.allow) return } @@ -1700,11 +1696,9 @@ extension TabViewController: WKNavigationDelegate { performBlobNavigation(navigationAction, completion: completion) case .duck: - if let handler = youtubeNavigationHandler { - handler.handleNavigation(navigationAction, webView: webView, completion: completion) - return - } + duckPlayerNavigationHandler.handleNavigation(navigationAction, webView: webView) completion(.cancel) + return case .unknown: if navigationAction.navigationType == .linkActivated { @@ -2353,7 +2347,7 @@ extension TabViewController: UserContentControllerDelegate { userScripts.autoconsentUserScript.delegate = self // Setup DuckPlayer - userScripts.duckPlayer = duckPlayer + userScripts.duckPlayer = duckPlayerNavigationHandler.duckPlayer userScripts.youtubeOverlayScript?.webView = webView userScripts.youtubePlayerUserScript?.webView = webView diff --git a/DuckDuckGo/TabViewControllerLongPressMenuExtension.swift b/DuckDuckGo/TabViewControllerLongPressMenuExtension.swift index e6a48170cd..b25fb38e35 100644 --- a/DuckDuckGo/TabViewControllerLongPressMenuExtension.swift +++ b/DuckDuckGo/TabViewControllerLongPressMenuExtension.swift @@ -105,7 +105,8 @@ extension TabViewController { let tabController = TabViewController.loadFromStoryboard(model: tab, bookmarksDatabase: bookmarksDatabase, historyManager: historyManager, - syncService: syncService) + syncService: syncService, + duckPlayerNavigationHandler: duckPlayerNavigationHandler) tabController.isLinkPreview = true let configuration = WKWebViewConfiguration.nonPersistent() tabController.attachWebView(configuration: configuration, andLoadRequest: URLRequest.userInitiated(url), consumeCookies: false) diff --git a/DuckDuckGoTests/DuckPlayerMocks.swift b/DuckDuckGoTests/DuckPlayerMocks.swift index aea76a748a..ed84be6f1d 100644 --- a/DuckDuckGoTests/DuckPlayerMocks.swift +++ b/DuckDuckGoTests/DuckPlayerMocks.swift @@ -110,6 +110,14 @@ final class MockDuckPlayerSettings: DuckPlayerSettingsProtocol { } final class MockDuckPlayer: DuckPlayerProtocol { + func initialSetupPlayer(params: Any, message: WKScriptMessage) async -> (any Encodable)? { + nil + } + + func initialSetupOverlay(params: Any, message: WKScriptMessage) async -> (any Encodable)? { + nil + } + var settings: any DuckPlayerSettingsProtocol init(settings: DuckPlayerSettingsProtocol) { diff --git a/DuckDuckGoTests/DuckPlayerURLExtensionTests.swift b/DuckDuckGoTests/DuckPlayerURLExtensionTests.swift index d5df8b9603..d45e6ca8f7 100644 --- a/DuckDuckGoTests/DuckPlayerURLExtensionTests.swift +++ b/DuckDuckGoTests/DuckPlayerURLExtensionTests.swift @@ -22,6 +22,12 @@ import os.log @testable import DuckDuckGo final class DuckPlayerURLExtensionTests: XCTestCase { + + #if os(iOS) + let baseUrl = "https://m.youtube.com" + #else + let baseUrl = "https://www.youtube.com" + #endif func testIsDuckPlayerScheme() { XCTAssertTrue("duck:player/abcdef12345".url!.isDuckURLScheme) @@ -29,7 +35,7 @@ final class DuckPlayerURLExtensionTests: XCTestCase { XCTAssertTrue("duck://player/abcdef".url!.isDuckURLScheme) XCTAssertTrue("duck://player/12345".url!.isDuckURLScheme) XCTAssertFalse("http://duckplayer/abcdef12345".url!.isDuckURLScheme) - XCTAssertFalse("https://www.youtube.com/watch?v=abcdef12345".url!.isDuckURLScheme) + XCTAssertFalse("\(baseUrl)/watch?v=abcdef12345".url!.isDuckURLScheme) XCTAssertFalse("https://www.youtube-nocookie.com/embed/abcdef12345".url!.isDuckURLScheme) } @@ -41,25 +47,25 @@ final class DuckPlayerURLExtensionTests: XCTestCase { XCTAssertFalse("https://www.youtube-nocookie.com/embed?t=23s".url!.isDuckPlayer) XCTAssertTrue("duck://player/abcdef12345".url!.isDuckPlayer) - XCTAssertFalse("https://www.youtube.com/watch?v=abcdef12345".url!.isDuckPlayer) + XCTAssertFalse("\(baseUrl)/watch?v=abcdef12345".url!.isDuckPlayer) XCTAssertFalse("https://duckduckgo.com".url!.isDuckPlayer) } func testIsYoutubePlaylist() { - XCTAssertTrue("https://www.youtube.com/watch?v=abcdef12345&list=abcdefgh12345678".url!.isYoutubePlaylist) - XCTAssertTrue("https://www.youtube.com/watch?list=abcdefgh12345678&v=abcdef12345".url!.isYoutubePlaylist) + XCTAssertTrue("\(baseUrl)/watch?v=abcdef12345&list=abcdefgh12345678".url!.isYoutubePlaylist) + XCTAssertTrue("\(baseUrl)/watch?list=abcdefgh12345678&v=abcdef12345".url!.isYoutubePlaylist) XCTAssertFalse("https://duckduckgo.com/watch?v=abcdef12345&list=abcdefgh12345678".url!.isYoutubePlaylist) - XCTAssertFalse("https://www.youtube.com/watch?list=abcdefgh12345678".url!.isYoutubePlaylist) - XCTAssertFalse("https://www.youtube.com/watch?v=abcdef12345&list=abcdefgh12345678&index=1".url!.isYoutubePlaylist) + XCTAssertFalse("\(baseUrl)/watch?list=abcdefgh12345678".url!.isYoutubePlaylist) + XCTAssertFalse("\(baseUrl)/watch?v=abcdef12345&list=abcdefgh12345678&index=1".url!.isYoutubePlaylist) } func testIsYoutubeVideo() { - XCTAssertTrue("https://www.youtube.com/watch?v=abcdef12345".url!.isYoutubeVideo) - XCTAssertTrue("https://www.youtube.com/watch?v=abcdef12345&list=abcdefgh12345678&index=1".url!.isYoutubeVideo) - XCTAssertTrue("https://www.youtube.com/watch?v=abcdef12345&t=5m".url!.isYoutubeVideo) + XCTAssertTrue("\(baseUrl)/watch?v=abcdef12345".url!.isYoutubeVideo) + XCTAssertTrue("\(baseUrl)/watch?v=abcdef12345&list=abcdefgh12345678&index=1".url!.isYoutubeVideo) + XCTAssertTrue("\(baseUrl)/watch?v=abcdef12345&t=5m".url!.isYoutubeVideo) - XCTAssertFalse("https://www.youtube.com/watch?v=abcdef12345&list=abcdefgh12345678".url!.isYoutubeVideo) + XCTAssertFalse("\(baseUrl)/watch?v=abcdef12345&list=abcdefgh12345678".url!.isYoutubeVideo) XCTAssertFalse("https://duckduckgo.com/watch?v=abcdef12345".url!.isYoutubeVideo) } @@ -74,15 +80,15 @@ final class DuckPlayerURLExtensionTests: XCTestCase { } func testYoutubeVideoParamsFromYoutubeURL() { - let params = "https://www.youtube.com/watch?v=abcdef12345".url!.youtubeVideoParams + let params = "\(baseUrl)/watch?v=abcdef12345".url!.youtubeVideoParams XCTAssertEqual(params?.videoID, "abcdef12345") XCTAssertEqual(params?.timestamp, nil) - let paramsWithTimestamp = "https://www.youtube.com/watch?v=abcdef12345&t=23s".url!.youtubeVideoParams + let paramsWithTimestamp = "\(baseUrl)/watch?v=abcdef12345&t=23s".url!.youtubeVideoParams XCTAssertEqual(paramsWithTimestamp?.videoID, "abcdef12345") XCTAssertEqual(paramsWithTimestamp?.timestamp, "23s") - let paramsWithTimestampWithoutUnits = "https://www.youtube.com/watch?t=102&v=abcdef12345&feature=youtu.be".url!.youtubeVideoParams + let paramsWithTimestampWithoutUnits = "\(baseUrl)/watch?t=102&v=abcdef12345&feature=youtu.be".url!.youtubeVideoParams XCTAssertEqual(paramsWithTimestampWithoutUnits?.videoID, "abcdef12345") XCTAssertEqual(paramsWithTimestampWithoutUnits?.timestamp, "102") } @@ -110,15 +116,15 @@ final class DuckPlayerURLExtensionTests: XCTestCase { } func testYoutubeURLTimestampValidation() { - XCTAssertEqual(URL.youtube("abcdef12345", timestamp: nil).absoluteString, "https://www.youtube.com/watch?v=abcdef12345") - XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "23s").absoluteString, "https://www.youtube.com/watch?v=abcdef12345&t=23s") - XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "5m5s").absoluteString, "https://www.youtube.com/watch?v=abcdef12345&t=5m5s") - XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "12h400m100s").absoluteString, "https://www.youtube.com/watch?v=abcdef12345&t=12h400m100s") - XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "12h2s2h").absoluteString, "https://www.youtube.com/watch?v=abcdef12345&t=12h2s2h") - XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "5m5m5m").absoluteString, "https://www.youtube.com/watch?v=abcdef12345&t=5m5m5m") - - XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "5").absoluteString, "https://www.youtube.com/watch?v=abcdef12345&t=5") - XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "10d").absoluteString, "https://www.youtube.com/watch?v=abcdef12345") + XCTAssertEqual(URL.youtube("abcdef12345", timestamp: nil).absoluteString, "\(baseUrl)/watch?v=abcdef12345") + XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "23s").absoluteString, "\(baseUrl)/watch?v=abcdef12345&t=23s") + XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "5m5s").absoluteString, "\(baseUrl)/watch?v=abcdef12345&t=5m5s") + XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "12h400m100s").absoluteString, "\(baseUrl)/watch?v=abcdef12345&t=12h400m100s") + XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "12h2s2h").absoluteString, "\(baseUrl)/watch?v=abcdef12345&t=12h2s2h") + XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "5m5m5m").absoluteString, "\(baseUrl)/watch?v=abcdef12345&t=5m5m5m") + + XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "5").absoluteString, "\(baseUrl)/watch?v=abcdef12345&t=5") + XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "10d").absoluteString, "\(baseUrl)/watch?v=abcdef12345") } func testYoutubeNoCookieURLTimestampValidation() { diff --git a/DuckDuckGoTests/YoutublePlayerNavigationHandlerTests.swift b/DuckDuckGoTests/YoutublePlayerNavigationHandlerTests.swift index fe4f76eb09..877980dc8e 100644 --- a/DuckDuckGoTests/YoutublePlayerNavigationHandlerTests.swift +++ b/DuckDuckGoTests/YoutublePlayerNavigationHandlerTests.swift @@ -25,14 +25,14 @@ import BrowserServicesKit @testable import DuckDuckGo -class YoutubePlayerNavigationHandlerTests: XCTestCase { +class DuckPlayerNavigationHandlerTests: XCTestCase { var webView: WKWebView! var mockWebView: MockWebView! var mockNavigationDelegate: MockWKNavigationDelegate! var mockAppSettings: AppSettingsMock! var mockPrivacyConfig: PrivacyConfigurationManagerMock! - + override func setUp() { super.setUp() webView = WKWebView() @@ -52,7 +52,7 @@ class YoutubePlayerNavigationHandlerTests: XCTestCase { // Test for htmlTemplatePath existence func testHtmlTemplatePathExists() { - let templatePath = YoutubePlayerNavigationHandler.htmlTemplatePath + let templatePath = DuckPlayerNavigationHandler.htmlTemplatePath let fileExists = FileManager.default.fileExists(atPath: templatePath) XCTAssertFalse(templatePath.isEmpty, "The template path should not be empty") XCTAssertTrue(fileExists, "The template file should exist at the specified path") @@ -62,7 +62,7 @@ class YoutubePlayerNavigationHandlerTests: XCTestCase { func testMakeDuckPlayerRequestFromOriginalRequest() { let originalRequest = URLRequest(url: URL(string: "https://www.youtube.com/watch?v=abc123&t=10s")!) - let duckPlayerRequest = YoutubePlayerNavigationHandler.makeDuckPlayerRequest(from: originalRequest) + let duckPlayerRequest = DuckPlayerNavigationHandler.makeDuckPlayerRequest(from: originalRequest) XCTAssertEqual(duckPlayerRequest.url?.host, "www.youtube-nocookie.com") XCTAssertEqual(duckPlayerRequest.url?.path, "/embed/abc123") @@ -76,7 +76,7 @@ class YoutubePlayerNavigationHandlerTests: XCTestCase { let videoID = "abc123" let timestamp = "10s" - let duckPlayerRequest = YoutubePlayerNavigationHandler.makeDuckPlayerRequest(for: videoID, timestamp: timestamp) + let duckPlayerRequest = DuckPlayerNavigationHandler.makeDuckPlayerRequest(for: videoID, timestamp: timestamp) XCTAssertEqual(duckPlayerRequest.url?.host, "www.youtube-nocookie.com") XCTAssertEqual(duckPlayerRequest.url?.path, "/embed/abc123") @@ -87,24 +87,23 @@ class YoutubePlayerNavigationHandlerTests: XCTestCase { // Test for makeHTMLFromTemplate func testMakeHTMLFromTemplate() { - let expectedHtml = try? String(contentsOfFile: YoutubePlayerNavigationHandler.htmlTemplatePath) - let html = YoutubePlayerNavigationHandler.makeHTMLFromTemplate() + let expectedHtml = try? String(contentsOfFile: DuckPlayerNavigationHandler.htmlTemplatePath) + let html = DuckPlayerNavigationHandler.makeHTMLFromTemplate() XCTAssertEqual(html, expectedHtml) } - // Test for handleURLChange + // MARK: handleURLChange tests @MainActor func testHandleURLChangeDuckPlayerEnabled() { let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s")! let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - playerSettings.setMode(.alwaysAsk) + playerSettings.setMode(.enabled) let player = MockDuckPlayer(settings: playerSettings) - let handler = YoutubePlayerNavigationHandler(duckPlayer: player) + let handler = DuckPlayerNavigationHandler(duckPlayer: player) handler.handleURLChange(url: youtubeURL, webView: mockWebView) - XCTAssertTrue(mockWebView.didStopLoadingCalled, "Expected stopLoading to be called") XCTAssertNotNil(mockWebView.lastLoadedRequest, "Expected a new request to be loaded") if let loadedRequest = mockWebView.lastLoadedRequest { @@ -113,6 +112,7 @@ class YoutubePlayerNavigationHandlerTests: XCTestCase { XCTAssertEqual(loadedRequest.url?.path, "/abc123") XCTAssertEqual(loadedRequest.url?.query?.contains("t=10s"), true) } + } @MainActor @@ -122,157 +122,147 @@ class YoutubePlayerNavigationHandlerTests: XCTestCase { let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) playerSettings.setMode(.disabled) let player = MockDuckPlayer(settings: playerSettings) - let handler = YoutubePlayerNavigationHandler(duckPlayer: player) + let handler = DuckPlayerNavigationHandler(duckPlayer: player) handler.handleURLChange(url: youtubeURL, webView: mockWebView) - XCTAssertFalse(mockWebView.didStopLoadingCalled, "Expected stopLoading Not to be called") - XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request not to be loaded") + XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request NOT to be loaded") } @MainActor - func testHandleURLChangeForNonYouTubeVideo() { + func testHandleURLChangeDuckPlayerTemporarilyDisabled() { + let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s")! + + let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) + playerSettings.setMode(.enabled) + let player = MockDuckPlayer(settings: playerSettings) + let handler = DuckPlayerNavigationHandler(duckPlayer: player) + + handler.isDuckPlayerTemporarilyDisabled = true + + handler.handleURLChange(url: youtubeURL, webView: mockWebView) + + XCTAssertNotNil(mockWebView.lastLoadedRequest, "Expected a new request to be loaded") + + if let loadedRequest = mockWebView.lastLoadedRequest { + XCTAssertEqual(loadedRequest.url?.scheme, "https") + XCTAssertEqual(loadedRequest.url?.host, "m.youtube.com") + XCTAssertEqual(loadedRequest.url?.path, "/watch") + XCTAssertEqual(loadedRequest.url?.query?.contains("v=abc123"), true) + XCTAssertEqual(loadedRequest.url?.query?.contains("t=10s"), true) + } + } + + @MainActor + func testHandleURLChangeNonYouTubeURL() { let nonYouTubeURL = URL(string: "https://www.google.com")! let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - playerSettings.setMode(.disabled) + playerSettings.setMode(.enabled) let player = MockDuckPlayer(settings: playerSettings) - let handler = YoutubePlayerNavigationHandler(duckPlayer: player) + let handler = DuckPlayerNavigationHandler(duckPlayer: player) handler.handleURLChange(url: nonYouTubeURL, webView: mockWebView) - XCTAssertFalse(mockWebView.didStopLoadingCalled, "Expected stopLoading not to be called") - XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request not to be loaded") + XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request NOT to be loaded") } - // Test for handleDecidePolicyFor @MainActor - func testHandleDecidePolicyForWithDuckPlayerEnabled() { - let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s")! - let navigationAction = MockNavigationAction(request: URLRequest(url: youtubeURL)) - let expectation = self.expectation(description: "Completion handler called") - - var navigationPolicy: WKNavigationActionPolicy? + func testHandleNavigationOpenInYoutubeLink() { + let duckURL = URL(string: "duck://player/openInYoutube?v=12345")! + let navigationAction = MockNavigationAction(request: URLRequest(url: duckURL)) let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - playerSettings.setMode(.alwaysAsk) + playerSettings.setMode(.enabled) let player = MockDuckPlayer(settings: playerSettings) - let handler = YoutubePlayerNavigationHandler(duckPlayer: player) - - handler.handleDecidePolicyFor(navigationAction, completion: { policy in - navigationPolicy = policy - expectation.fulfill() - }, webView: mockWebView) + let handler = DuckPlayerNavigationHandler(duckPlayer: player) - waitForExpectations(timeout: 1, handler: nil) + handler.handleNavigation(navigationAction, webView: mockWebView) - XCTAssertEqual(navigationPolicy, .allow, "Expected navigation policy to be .allow") + XCTAssertTrue(handler.isDuckPlayerTemporarilyDisabled, "Expected DuckPlayer to be temporarily disabled") XCTAssertNotNil(mockWebView.lastLoadedRequest, "Expected a new request to be loaded") if let loadedRequest = mockWebView.lastLoadedRequest { - XCTAssertEqual(loadedRequest.url?.scheme, "duck") - XCTAssertEqual(loadedRequest.url?.host, "player") - XCTAssertEqual(loadedRequest.url?.path, "/abc123") - XCTAssertEqual(loadedRequest.url?.query?.contains("t=10s"), true) + XCTAssertEqual(loadedRequest.url?.scheme, "https") + XCTAssertEqual(loadedRequest.url?.host, "m.youtube.com") + XCTAssertEqual(loadedRequest.url?.path, "/watch") + XCTAssertEqual(loadedRequest.url?.query?.contains("v=12345"), true) } - } @MainActor - func testHandleDecidePolicyForWithDuckPlayerDisabled() { - let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s")! - let navigationAction = MockNavigationAction(request: URLRequest(url: youtubeURL)) - let expectation = self.expectation(description: "Completion handler called") - - var navigationPolicy: WKNavigationActionPolicy? + func testHandleNavigationDuckPlayerEnabledAlreadyInDuckPlayer() { + let duckPlayerURL = URL(string: "duck://player/CYTASDSD")! + let navigationAction = MockNavigationAction(request: URLRequest(url: duckPlayerURL)) let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - playerSettings.setMode(.disabled) let player = MockDuckPlayer(settings: playerSettings) - let handler = YoutubePlayerNavigationHandler(duckPlayer: player) + player.settings.setMode(.enabled) - handler.handleDecidePolicyFor(navigationAction, completion: { policy in - navigationPolicy = policy - expectation.fulfill() - }, webView: mockWebView) + let handler = DuckPlayerNavigationHandler(duckPlayer: player) + handler.handleNavigation(navigationAction, webView: mockWebView) - waitForExpectations(timeout: 1, handler: nil) - - XCTAssertEqual(navigationPolicy, .allow, "Expected navigation policy to be .allow") XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request to be loaded") - + } + @MainActor - func testHandleDecidePolicyForNonYouTubeVideoWithDuckPlayerEnabled() { - let nonYouTubeURL = URL(string: "https://www.google.com")! - let navigationAction = MockNavigationAction(request: URLRequest(url: nonYouTubeURL)) - let expectation = self.expectation(description: "Completion handler called") - - var navigationPolicy: WKNavigationActionPolicy? + func testHandleNavigationDuckPlayerDisabled() { + let duckPlayerURL = URL(string: "duck://player/CUIUIIUI")! + let navigationAction = MockNavigationAction(request: URLRequest(url: duckPlayerURL)) let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - playerSettings.setMode(.alwaysAsk) + playerSettings.setMode(.disabled) let player = MockDuckPlayer(settings: playerSettings) - let handler = YoutubePlayerNavigationHandler(duckPlayer: player) - - handler.handleDecidePolicyFor(navigationAction, completion: { policy in - navigationPolicy = policy - expectation.fulfill() - }, webView: mockWebView) + let handler = DuckPlayerNavigationHandler(duckPlayer: player) + + handler.handleNavigation(navigationAction, webView: mockWebView) - waitForExpectations(timeout: 1, handler: nil) + XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request to be loaded") - XCTAssertEqual(navigationPolicy, .allow, "Expected navigation policy to be .allow") - XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request not to be loaded") } @MainActor - func testHandleDecidePolicyForNonYouTubeVideoWithDuckPlayerDisabled() { - let nonYouTubeURL = URL(string: "https://www.google.com")! - let navigationAction = MockNavigationAction(request: URLRequest(url: nonYouTubeURL)) - let expectation = self.expectation(description: "Completion handler called") - - var navigationPolicy: WKNavigationActionPolicy? - + func testHandleDecidePolicyForVideoJustHandled() { + let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s")! + let navigationAction = MockNavigationAction(request: URLRequest(url: youtubeURL)) let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - playerSettings.setMode(.disabled) + playerSettings.setMode(.enabled) let player = MockDuckPlayer(settings: playerSettings) - let handler = YoutubePlayerNavigationHandler(duckPlayer: player) - - handler.handleDecidePolicyFor(navigationAction, completion: { policy in - navigationPolicy = policy + let handler = DuckPlayerNavigationHandler(duckPlayer: player) + + // Call handleDecidePolicyFor twice with the same URL to simulate handling the same video twice + handler.handleDecidePolicyFor(navigationAction, webView: mockWebView) + + // Wait for 0.8 seconds to simulate the time delay + let expectation = self.expectation(description: "Wait for 0.8 seconds") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { + handler.handleDecidePolicyFor(navigationAction, webView: self.mockWebView) expectation.fulfill() - }, webView: mockWebView) + } - waitForExpectations(timeout: 1, handler: nil) + waitForExpectations(timeout: 1.0, handler: nil) - XCTAssertEqual(navigationPolicy, .allow, "Expected navigation policy to be .allow") - XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request not to be loaded") + // Verify that the second call did not load a new request + XCTAssertNil(mockWebView.lastLoadedRequest, "Expected no new request to be loaded since video was just handled") } @MainActor - func testHandleReloadForDuckPlayerVideoWithDuckPlayerEnabled() { - let duckPlayerURL = URL(string: "https://www.youtube-nocookie.com/embed/abc123?t=10s")! - - mockWebView.setCurrentURL(duckPlayerURL) + func testHandleDecidePolicyForTransformYoutubeURL() { + let youtubeURL = URL(string: "https://m.youtube.com/watch?v=abc123&t=10s")! + let navigationAction = MockNavigationAction(request: URLRequest(url: youtubeURL)) let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - playerSettings.setMode(.alwaysAsk) + playerSettings.setMode(.enabled) let player = MockDuckPlayer(settings: playerSettings) - let handler = YoutubePlayerNavigationHandler(duckPlayer: player) - - handler.handleReload(webView: mockWebView) + let handler = DuckPlayerNavigationHandler(duckPlayer: player) - XCTAssertNotNil(mockWebView.lastLoadedRequest, "Expected a new request to be loaded") + handler.handleDecidePolicyFor(navigationAction, webView: mockWebView) - if let loadedRequest = mockWebView.lastLoadedRequest { - XCTAssertEqual(loadedRequest.url?.scheme, "duck") - XCTAssertEqual(loadedRequest.url?.host, "player") - XCTAssertEqual(loadedRequest.url?.path, "/abc123") - XCTAssertEqual(loadedRequest.url?.query?.contains("t=10s"), true) - } + XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request to be loaded") + } @MainActor @@ -284,14 +274,14 @@ class YoutubePlayerNavigationHandlerTests: XCTestCase { let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) playerSettings.setMode(.disabled) let player = MockDuckPlayer(settings: playerSettings) - let handler = YoutubePlayerNavigationHandler(duckPlayer: player) + let handler = DuckPlayerNavigationHandler(duckPlayer: player) handler.handleReload(webView: mockWebView) XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request not to be loaded") } - + @MainActor func testHandleReloadForNonDuckPlayerVideoWithDuckPlayerEnabled() { let nonDuckPlayerURL = URL(string: "https://www.google.com")! @@ -302,7 +292,7 @@ class YoutubePlayerNavigationHandlerTests: XCTestCase { let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) playerSettings.setMode(.alwaysAsk) let player = MockDuckPlayer(settings: playerSettings) - let handler = YoutubePlayerNavigationHandler(duckPlayer: player) + let handler = DuckPlayerNavigationHandler(duckPlayer: player) handler.handleReload(webView: mockWebView) XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request not to be loaded") @@ -318,10 +308,10 @@ class YoutubePlayerNavigationHandlerTests: XCTestCase { let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) playerSettings.setMode(.disabled) let player = MockDuckPlayer(settings: playerSettings) - let handler = YoutubePlayerNavigationHandler(duckPlayer: player) + let handler = DuckPlayerNavigationHandler(duckPlayer: player) handler.handleReload(webView: mockWebView) XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request not to be loaded") } - + } diff --git a/submodules/privacy-reference-tests b/submodules/privacy-reference-tests index afb4f6128a..a603ff9af2 160000 --- a/submodules/privacy-reference-tests +++ b/submodules/privacy-reference-tests @@ -1 +1 @@ -Subproject commit afb4f6128a3b50d53ddcb1897ea1fb4df6858aa1 +Subproject commit a603ff9af22ca3ff7ce2e7ffbfe18c447d9f23e8 From bc098bb8176f3e728ee15cbbd0d37d946a6b6a98 Mon Sep 17 00:00:00 2001 From: Daniel Bernal Date: Fri, 19 Jul 2024 18:37:05 +0200 Subject: [PATCH 06/24] [DuckPlayer] 7- Open Settings (#3110) Task/Issue URL: https://app.asana.com/0/1204099484721401/1207821841500245/f Description: Tapping on the "Settings" button, open app settings in DuckPlayer Tapping on the "Info" button, opens DuckPlayer info sheet --- DuckDuckGo/DuckPlayer/DuckPlayer.swift | 32 +++++++++++++++++++ .../DuckPlayer/YoutubePlayerUserScript.swift | 6 ++++ DuckDuckGo/MainViewController.swift | 19 +++++++++++ DuckDuckGo/SettingsRootView.swift | 2 ++ DuckDuckGo/SettingsViewModel.swift | 7 ++++ DuckDuckGoTests/DuckPlayerMocks.swift | 12 +++++++ 6 files changed, 78 insertions(+) diff --git a/DuckDuckGo/DuckPlayer/DuckPlayer.swift b/DuckDuckGo/DuckPlayer/DuckPlayer.swift index df7e5ecf29..4192367a0d 100644 --- a/DuckDuckGo/DuckPlayer/DuckPlayer.swift +++ b/DuckDuckGo/DuckPlayer/DuckPlayer.swift @@ -81,15 +81,20 @@ public enum DuckPlayerReferrer { protocol DuckPlayerProtocol { var settings: DuckPlayerSettingsProtocol { get } + var hostView: UIViewController? { get } init(settings: DuckPlayerSettingsProtocol) func setUserValues(params: Any, message: WKScriptMessage) -> Encodable? func getUserValues(params: Any, message: WKScriptMessage) -> Encodable? func openVideoInDuckPlayer(url: URL, webView: WKWebView) + func openDuckPlayerSettings(params: Any, message: WKScriptMessage) async -> Encodable? + func openDuckPlayerInfo(params: Any, message: WKScriptMessage) async -> Encodable? func initialSetupPlayer(params: Any, message: WKScriptMessage) async -> Encodable? func initialSetupOverlay(params: Any, message: WKScriptMessage) async -> Encodable? + + func setHostViewController(_ vc: UIViewController) } final class DuckPlayer: DuckPlayerProtocol { @@ -98,11 +103,18 @@ final class DuckPlayer: DuckPlayerProtocol { static let commonName = "Duck Player" private(set) var settings: DuckPlayerSettingsProtocol + private(set) var hostView: UIViewController? init(settings: DuckPlayerSettingsProtocol = DuckPlayerSettings()) { self.settings = settings } + // Sets a presenting VC, so DuckPlayer can present the + // info sheet directly + public func setHostViewController(_ vc: UIViewController) { + hostView = vc + } + // MARK: - Common Message Handlers public func setUserValues(params: Any, message: WKScriptMessage) -> Encodable? { @@ -135,6 +147,26 @@ final class DuckPlayer: DuckPlayerProtocol { let webView = message.webView return await self.encodedPlayerSettings(with: webView) } + + public func openDuckPlayerSettings(params: Any, message: WKScriptMessage) async -> Encodable? { + NotificationCenter.default.post( + name: .settingsDeepLinkNotification, + object: SettingsViewModel.SettingsDeepLinkSection.duckPlayer, + userInfo: nil + ) + return nil + } + + @MainActor + public func presentDuckPlayerInfo() { + guard let hostView else { return } + DuckPlayerModalPresenter().presentDuckPlayerFeatureModal(on: hostView) + } + + public func openDuckPlayerInfo(params: Any, message: WKScriptMessage) async -> Encodable? { + await presentDuckPlayerInfo() + return nil + } private func encodeUserValues() -> UserValues { UserValues( diff --git a/DuckDuckGo/DuckPlayer/YoutubePlayerUserScript.swift b/DuckDuckGo/DuckPlayer/YoutubePlayerUserScript.swift index af283c3615..e85f0ee19b 100644 --- a/DuckDuckGo/DuckPlayer/YoutubePlayerUserScript.swift +++ b/DuckDuckGo/DuckPlayer/YoutubePlayerUserScript.swift @@ -35,6 +35,8 @@ final class YoutubePlayerUserScript: NSObject, Subfeature { static let setUserValues = "setUserValues" static let getUserValues = "getUserValues" static let initialSetup = "initialSetup" + static let openSettings = "openSettings" + static let openInfo = "openInfo" } init(duckPlayer: DuckPlayerProtocol) { @@ -73,6 +75,10 @@ final class YoutubePlayerUserScript: NSObject, Subfeature { return duckPlayer.setUserValues case Handlers.initialSetup: return duckPlayer.initialSetupPlayer + case Handlers.openSettings: + return duckPlayer.openDuckPlayerSettings + case Handlers.openInfo: + return duckPlayer.openDuckPlayerInfo default: assertionFailure("YoutubePlayerUserScript: Failed to parse User Script message: \(methodName)") return nil diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index 4399ff1653..51675e1d04 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -113,6 +113,7 @@ class MainViewController: UIViewController { private var favoritesDisplayModeCancellable: AnyCancellable? private var emailCancellables = Set() private var urlInterceptorCancellables = Set() + private var settingsDeepLinkcancellables = Set() #if NETWORK_PROTECTION private let tunnelDefaults = UserDefaults.networkProtectionGroupDefaults @@ -261,6 +262,7 @@ class MainViewController: UIViewController { addLaunchTabNotificationObserver() subscribeToEmailProtectionStatusNotifications() subscribeToURLInterceptorNotifications() + subscribeToSettingsDeeplinkNotifications() #if NETWORK_PROTECTION subscribeToNetworkProtectionEvents() @@ -1349,6 +1351,23 @@ class MainViewController: UIViewController { } .store(in: &urlInterceptorCancellables) } + + private func subscribeToSettingsDeeplinkNotifications() { + NotificationCenter.default.publisher(for: .settingsDeepLinkNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + switch notification.object as? SettingsViewModel.SettingsDeepLinkSection { + + case .duckPlayer: + let deepLinkTarget: SettingsViewModel.SettingsDeepLinkSection + deepLinkTarget = .duckPlayer + self?.launchSettings(deepLinkTarget: deepLinkTarget) + default: + return + } + } + .store(in: &settingsDeepLinkcancellables) + } #if NETWORK_PROTECTION private func subscribeToNetworkProtectionEvents() { diff --git a/DuckDuckGo/SettingsRootView.swift b/DuckDuckGo/SettingsRootView.swift index 32d4599313..415ce3d3bd 100644 --- a/DuckDuckGo/SettingsRootView.swift +++ b/DuckDuckGo/SettingsRootView.swift @@ -117,6 +117,8 @@ struct SettingsRootView: View { SubscriptionContainerViewFactory.makeSubscribeFlow(origin: origin, navigationCoordinator: subscriptionNavigationCoordinator, subscriptionManager: AppDependencyProvider.shared.subscriptionManager) + case .duckPlayer: + SettingsDuckPlayerView().environmentObject(viewModel) default: EmptyView() } diff --git a/DuckDuckGo/SettingsViewModel.swift b/DuckDuckGo/SettingsViewModel.swift index f437dd7d4f..a31df34ba0 100644 --- a/DuckDuckGo/SettingsViewModel.swift +++ b/DuckDuckGo/SettingsViewModel.swift @@ -634,6 +634,7 @@ extension SettingsViewModel { case dbp case itr case subscriptionFlow(origin: String? = nil) + case duckPlayer // Add other cases as needed var id: String { @@ -642,6 +643,7 @@ extension SettingsViewModel { case .dbp: return "dbp" case .itr: return "itr" case .subscriptionFlow: return "subscriptionFlow" + case .duckPlayer: return "duckPlayer" // Ensure all cases are covered } } @@ -791,3 +793,8 @@ extension SettingsViewModel { } } + +// Deeplink notification handling +extension NSNotification.Name { + static let settingsDeepLinkNotification: NSNotification.Name = Notification.Name(rawValue: "com.duckduckgo.notification.settingsDeepLink") +} diff --git a/DuckDuckGoTests/DuckPlayerMocks.swift b/DuckDuckGoTests/DuckPlayerMocks.swift index ed84be6f1d..09d95354a8 100644 --- a/DuckDuckGoTests/DuckPlayerMocks.swift +++ b/DuckDuckGoTests/DuckPlayerMocks.swift @@ -110,6 +110,18 @@ final class MockDuckPlayerSettings: DuckPlayerSettingsProtocol { } final class MockDuckPlayer: DuckPlayerProtocol { + var hostView: UIViewController? + + func openDuckPlayerSettings(params: Any, message: WKScriptMessage) async -> (any Encodable)? { + nil + } + + func openDuckPlayerInfo(params: Any, message: WKScriptMessage) async -> (any Encodable)? { + nil + } + + func setHostViewController(_ vc: UIViewController) {} + func initialSetupPlayer(params: Any, message: WKScriptMessage) async -> (any Encodable)? { nil } From 4486729cf94fcd297591326aeba2c139ff8a3566 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Fri, 19 Jul 2024 09:40:41 -0700 Subject: [PATCH 07/24] Update BSK for Mac RMF changes (#3107) Task/Issue URL: https://app.asana.com/0/1199333091098016/1207851439577905/f Tech Design URL: CC: Description: This PR updates BSK for Mac RMF changes. iOS is not affected. --- DuckDuckGo.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index cc96638f15..7b92881927 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -10182,7 +10182,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 172.0.1; + version = 173.0.0; }; }; 9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a8a4f9f886..5720e2d319 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DuckDuckGo/BrowserServicesKit", "state" : { - "revision" : "3274feb8d84fda5f27541c13f2ab428b4e77a5e2", - "version" : "172.0.1" + "revision" : "16686ec1d3a8641b47c55d5271e29c7dbe4c9e73", + "version" : "173.0.0" } }, { From ba71738dcb56dc8a370b4aa0cde72bdbb4cbbd9d Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Fri, 19 Jul 2024 10:34:11 -0700 Subject: [PATCH 08/24] Reduce VPN manager instances (#3097) Task/Issue URL: https://app.asana.com/0/72649045549333/1207151621945908/f Tech Design URL: CC: Description: This PR reduces the number of VPN manager instances that we create through the lifetime of the app. --- DuckDuckGo.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 6 +- DuckDuckGo/AppDependencyProvider.swift | 2 + ...orkProtectionConvenienceInitialisers.swift | 2 +- .../NetworkProtectionDebugUtilities.swift | 8 +- DuckDuckGo/NetworkProtectionRootView.swift | 1 + DuckDuckGo/NetworkProtectionStatusView.swift | 8 +- .../NetworkProtectionStatusViewModel.swift | 67 +++++----- .../NetworkProtectionTunnelController.swift | 115 +++++++++++------- DuckDuckGoTests/MockDependencyProvider.swift | 2 + 10 files changed, 125 insertions(+), 88 deletions(-) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 7b92881927..5131c32908 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -10182,7 +10182,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 173.0.0; + version = 174.0.0; }; }; 9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5720e2d319..cfa33a3392 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DuckDuckGo/BrowserServicesKit", "state" : { - "revision" : "16686ec1d3a8641b47c55d5271e29c7dbe4c9e73", - "version" : "173.0.0" + "revision" : "6db80afec11da4f0a36a81dc6030f7e83a524c87", + "version" : "174.0.0" } }, { @@ -138,7 +138,7 @@ { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", + "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { "revision" : "0fbc8848e389af3bb55c182bc19ca9d5dc2f255b", "version" : "1.4.0" diff --git a/DuckDuckGo/AppDependencyProvider.swift b/DuckDuckGo/AppDependencyProvider.swift index 228093422b..fea06bf74e 100644 --- a/DuckDuckGo/AppDependencyProvider.swift +++ b/DuckDuckGo/AppDependencyProvider.swift @@ -47,6 +47,7 @@ protocol DependencyProvider { var networkProtectionKeychainTokenStore: NetworkProtectionKeychainTokenStore { get } var networkProtectionTunnelController: NetworkProtectionTunnelController { get } var connectionObserver: ConnectionStatusObserver { get } + var serverInfoObserver: ConnectionServerInfoObserver { get } var vpnSettings: VPNSettings { get } } @@ -88,6 +89,7 @@ class AppDependencyProvider: DependencyProvider { let subscriptionAppGroup = Bundle.main.appGroup(bundle: .subs) let connectionObserver: ConnectionStatusObserver = ConnectionStatusObserverThroughSession() + let serverInfoObserver: ConnectionServerInfoObserver = ConnectionServerInfoObserverThroughSession() let vpnSettings = VPNSettings(defaults: .networkProtectionGroupDefaults) init() { diff --git a/DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift b/DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift index cfa8429d58..0840e6fe1b 100644 --- a/DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift +++ b/DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift @@ -28,7 +28,7 @@ import Subscription private class DefaultTunnelSessionProvider: TunnelSessionProvider { func activeSession() async -> NETunnelProviderSession? { - try? await ConnectionSessionUtilities.activeSession() + return await AppDependencyProvider.shared.networkProtectionTunnelController.activeSession() } } diff --git a/DuckDuckGo/NetworkProtectionDebugUtilities.swift b/DuckDuckGo/NetworkProtectionDebugUtilities.swift index 22a16a2b83..0186856979 100644 --- a/DuckDuckGo/NetworkProtectionDebugUtilities.swift +++ b/DuckDuckGo/NetworkProtectionDebugUtilities.swift @@ -31,7 +31,7 @@ final class NetworkProtectionDebugUtilities { // MARK: - Registation Key func expireRegistrationKeyNow() async { - guard let activeSession = try? await ConnectionSessionUtilities.activeSession() else { + guard let activeSession = await AppDependencyProvider.shared.networkProtectionTunnelController.activeSession() else { return } @@ -41,7 +41,7 @@ final class NetworkProtectionDebugUtilities { // MARK: - Notifications func sendTestNotificationRequest() async throws { - guard let activeSession = try? await ConnectionSessionUtilities.activeSession() else { + guard let activeSession = await AppDependencyProvider.shared.networkProtectionTunnelController.activeSession() else { return } @@ -51,7 +51,7 @@ final class NetworkProtectionDebugUtilities { // MARK: - Disable VPN func disableConnectOnDemandAndShutDown() async { - guard let activeSession = try? await ConnectionSessionUtilities.activeSession() else { + guard let activeSession = await AppDependencyProvider.shared.networkProtectionTunnelController.activeSession() else { return } @@ -61,7 +61,7 @@ final class NetworkProtectionDebugUtilities { // MARK: - Failure Simulation func triggerSimulation(_ option: NetworkProtectionSimulationOption) async { - guard let activeSession = try? await ConnectionSessionUtilities.activeSession() else { + guard let activeSession = await AppDependencyProvider.shared.networkProtectionTunnelController.activeSession() else { return } diff --git a/DuckDuckGo/NetworkProtectionRootView.swift b/DuckDuckGo/NetworkProtectionRootView.swift index bdad605a52..1d23f301a9 100644 --- a/DuckDuckGo/NetworkProtectionRootView.swift +++ b/DuckDuckGo/NetworkProtectionRootView.swift @@ -33,6 +33,7 @@ struct NetworkProtectionRootView: View { statusViewModel = NetworkProtectionStatusViewModel(tunnelController: AppDependencyProvider.shared.networkProtectionTunnelController, settings: AppDependencyProvider.shared.vpnSettings, statusObserver: AppDependencyProvider.shared.connectionObserver, + serverInfoObserver: AppDependencyProvider.shared.serverInfoObserver, locationListRepository: locationListRepository) } var body: some View { diff --git a/DuckDuckGo/NetworkProtectionStatusView.swift b/DuckDuckGo/NetworkProtectionStatusView.swift index 67abeb033a..b98cc348da 100644 --- a/DuckDuckGo/NetworkProtectionStatusView.swift +++ b/DuckDuckGo/NetworkProtectionStatusView.swift @@ -202,11 +202,9 @@ struct NetworkProtectionStatusView: View { @ViewBuilder private func about() -> some View { Section { - if statusModel.shouldShowFAQ { - NavigationLink(UserText.netPVPNSettingsFAQ, destination: LazyView(NetworkProtectionFAQView())) - .daxBodyRegular() - .foregroundColor(.init(designSystemColor: .textPrimary)) - } + NavigationLink(UserText.netPVPNSettingsFAQ, destination: LazyView(NetworkProtectionFAQView())) + .daxBodyRegular() + .foregroundColor(.init(designSystemColor: .textPrimary)) NavigationLink(UserText.netPVPNSettingsShareFeedback, destination: VPNFeedbackFormCategoryView()) .daxBodyRegular() diff --git a/DuckDuckGo/NetworkProtectionStatusViewModel.swift b/DuckDuckGo/NetworkProtectionStatusViewModel.swift index 2509a21e73..8419921d0d 100644 --- a/DuckDuckGo/NetworkProtectionStatusViewModel.swift +++ b/DuckDuckGo/NetworkProtectionStatusViewModel.swift @@ -87,7 +87,7 @@ final class NetworkProtectionStatusViewModel: ObservableObject { return formatter }() - private let tunnelController: TunnelController + private let tunnelController: (TunnelController & TunnelSessionProvider) private let statusObserver: ConnectionStatusObserver private let serverInfoObserver: ConnectionServerInfoObserver private let errorObserver: ConnectionErrorObserver @@ -134,16 +134,12 @@ final class NetworkProtectionStatusViewModel: ObservableObject { @Published public var downloadTotal: String? private var throughputUpdateTimer: Timer? - var shouldShowFAQ: Bool { - AppDependencyProvider.shared.subscriptionFeatureAvailability.isFeatureAvailable - } - @Published public var animationsOn: Bool = false - public init(tunnelController: TunnelController, + public init(tunnelController: (TunnelController & TunnelSessionProvider), settings: VPNSettings, statusObserver: ConnectionStatusObserver, - serverInfoObserver: ConnectionServerInfoObserver = ConnectionServerInfoObserverThroughSession(), + serverInfoObserver: ConnectionServerInfoObserver, errorObserver: ConnectionErrorObserver = ConnectionErrorObserverThroughSession(), locationListRepository: NetworkProtectionLocationListRepository) { self.tunnelController = tunnelController @@ -159,6 +155,8 @@ final class NetworkProtectionStatusViewModel: ObservableObject { self.dnsSettings = settings.dnsSettings + updateViewModel(withStatus: statusObserver.recentValue) + setUpIsConnectedStatePublishers() setUpToggledStatePublisher() setUpStatusMessagePublishers() @@ -176,30 +174,10 @@ final class NetworkProtectionStatusViewModel: ObservableObject { } private func setUpIsConnectedStatePublishers() { - let isConnectedPublisher = statusObserver.publisher - .map { $0.isConnected } - .receive(on: DispatchQueue.main) - .eraseToAnyPublisher() - isConnectedPublisher - .map(Self.titleText(connected:)) - .assign(to: \.headerTitle, onWeaklyHeld: self) - .store(in: &cancellables) - isConnectedPublisher - .map(Self.statusImageID(connected:)) - .assign(to: \.statusImageID, onWeaklyHeld: self) - .store(in: &cancellables) - isConnectedPublisher - .sink { [weak self] isConnected in - if !isConnected { - self?.uploadTotal = nil - self?.downloadTotal = nil - self?.throughputUpdateTimer?.invalidate() - self?.throughputUpdateTimer = nil - } else { - self?.setUpThroughputRefreshTimer() - } - } - .store(in: &cancellables) + statusObserver.publisher.sink { [weak self] status in + self?.updateViewModel(withStatus: status) + } + .store(in: &cancellables) } private func setUpToggledStatePublisher() { @@ -292,6 +270,31 @@ final class NetworkProtectionStatusViewModel: ObservableObject { .store(in: &cancellables) } + private func updateViewModel(withStatus connectionStatus: ConnectionStatus) { + self.headerTitle = Self.titleText(connected: connectionStatus.isConnected) + self.statusImageID = Self.statusImageID(connected: connectionStatus.isConnected) + + if !connectionStatus.isConnected { + self.uploadTotal = nil + self.downloadTotal = nil + self.throughputUpdateTimer?.invalidate() + self.throughputUpdateTimer = nil + } else { + self.setUpThroughputRefreshTimer() + } + + switch connectionStatus { + case .connected: + self.isNetPEnabled = true + case .connecting: + self.isNetPEnabled = true + self.resetConnectionInformation() + default: + self.isNetPEnabled = false + self.resetConnectionInformation() + } + } + private func setUpErrorPublishers() { guard AppDependencyProvider.shared.internalUserDecider.isInternalUser else { return @@ -346,7 +349,7 @@ final class NetworkProtectionStatusViewModel: ObservableObject { } private func refreshDataVolumeTotals() async { - guard let activeSession = try? await ConnectionSessionUtilities.activeSession() else { + guard let activeSession = await tunnelController.activeSession() else { return } diff --git a/DuckDuckGo/NetworkProtectionTunnelController.swift b/DuckDuckGo/NetworkProtectionTunnelController.swift index 02f5953de7..30ef1e6f80 100644 --- a/DuckDuckGo/NetworkProtectionTunnelController.swift +++ b/DuckDuckGo/NetworkProtectionTunnelController.swift @@ -33,9 +33,10 @@ enum VPNConfigurationRemovalReason: String { case debugMenu } -final class NetworkProtectionTunnelController: TunnelController { +final class NetworkProtectionTunnelController: TunnelController, TunnelSessionProvider { static var shouldSimulateFailure: Bool = false + private var internalManager: NETunnelProviderManager? private let debugFeatures = NetworkProtectionDebugFeatures() private let tokenStore: NetworkProtectionKeychainTokenStore private let errorStore = NetworkProtectionTunnelErrorStore() @@ -43,6 +44,44 @@ final class NetworkProtectionTunnelController: TunnelController { private var previousStatus: NEVPNStatus = .invalid private var cancellables = Set() + // MARK: - Manager, Session, & Connection + + /// The tunnel manager: will try to load if it its not loaded yet, but if one can't be loaded from preferences, + /// a new one will not be created. This is useful for querying the connection state and information without triggering + /// a VPN-access popup to the user. + /// + @MainActor var tunnelManager: NETunnelProviderManager? { + get async { + if let internalManager { + return internalManager + } + + let loadedManager = try? await NETunnelProviderManager.loadAllFromPreferences().first + internalManager = loadedManager + return loadedManager + } + } + + public var connection: NEVPNConnection? { + get async { + await tunnelManager?.connection + } + } + + public func activeSession() async -> NETunnelProviderSession? { + await session + } + + public var session: NETunnelProviderSession? { + get async { + guard let manager = await tunnelManager, let session = manager.connection as? NETunnelProviderSession else { + return nil + } + + return session + } + } + // MARK: - Starting & Stopping the VPN enum StartError: LocalizedError, CustomNSError { @@ -83,6 +122,7 @@ final class NetworkProtectionTunnelController: TunnelController { init(accountManager: AccountManager, tokenStore: NetworkProtectionKeychainTokenStore) { self.tokenStore = tokenStore subscribeToStatusChanges() + subscribeToConfigurationChanges() } /// Starts the VPN connection used for Network Protection @@ -106,7 +146,7 @@ final class NetworkProtectionTunnelController: TunnelController { } func stop() async { - guard let tunnelManager = await loadTunnelManager() else { + guard let tunnelManager = await self.tunnelManager else { return } @@ -139,8 +179,7 @@ final class NetworkProtectionTunnelController: TunnelController { var isInstalled: Bool { get async { - let tunnelManager = await loadTunnelManager() - return tunnelManager != nil + return await self.tunnelManager != nil } } @@ -150,7 +189,7 @@ final class NetworkProtectionTunnelController: TunnelController { /// var isConnected: Bool { get async { - guard let tunnelManager = await loadTunnelManager() else { + guard let tunnelManager = await self.tunnelManager else { return false } @@ -174,7 +213,7 @@ final class NetworkProtectionTunnelController: TunnelController { switch tunnelManager.connection.status { case .invalid: - reloadTunnelManager() + clearInternalManager() try await startWithError() case .connected: // Intentional no-op @@ -184,10 +223,8 @@ final class NetworkProtectionTunnelController: TunnelController { } } - /// Reloads the tunnel manager from preferences. - /// - private func reloadTunnelManager() { - internalTunnelManager = nil + private func clearInternalManager() { + internalManager = nil } private func start(_ tunnelManager: NETunnelProviderManager) throws { @@ -224,35 +261,11 @@ final class NetworkProtectionTunnelController: TunnelController { } } - /// The actual storage for our tunnel manager. - /// - private var internalTunnelManager: NETunnelProviderManager? - - /// The tunnel manager: will try to load if it its not loaded yet, but if one can't be loaded from preferences, - /// a new one will not be created. This is useful for querying the connection state and information without triggering - /// a VPN-access popup to the user. - /// - private var tunnelManager: NETunnelProviderManager? { - get async { - guard let tunnelManager = internalTunnelManager else { - let tunnelManager = await loadTunnelManager() - internalTunnelManager = tunnelManager - return tunnelManager - } - - return tunnelManager - } - } - - private func loadTunnelManager() async -> NETunnelProviderManager? { - try? await NETunnelProviderManager.loadAllFromPreferences().first - } - private func loadOrMakeTunnelManager() async throws -> NETunnelProviderManager { guard let tunnelManager = await tunnelManager else { let tunnelManager = NETunnelProviderManager() try await setupAndSave(tunnelManager) - internalTunnelManager = tunnelManager + internalManager = tunnelManager return tunnelManager } @@ -262,12 +275,7 @@ final class NetworkProtectionTunnelController: TunnelController { private func setupAndSave(_ tunnelManager: NETunnelProviderManager) async throws { setup(tunnelManager) - try await saveToPreferences(tunnelManager) - try await loadFromPreferences(tunnelManager) - try await saveToPreferences(tunnelManager) - } - private func saveToPreferences(_ tunnelManager: NETunnelProviderManager) async throws { do { try await tunnelManager.saveToPreferences() } catch { @@ -281,9 +289,7 @@ final class NetworkProtectionTunnelController: TunnelController { } throw StartError.saveToPreferencesFailed(error) } - } - private func loadFromPreferences(_ tunnelManager: NETunnelProviderManager) async throws { do { try await tunnelManager.loadFromPreferences() } catch { @@ -311,6 +317,31 @@ final class NetworkProtectionTunnelController: TunnelController { tunnelManager.onDemandRules = [NEOnDemandRuleConnect()] } + // MARK: - Observing Configuration Changes + + private func subscribeToConfigurationChanges() { + notificationCenter.publisher(for: .NEVPNConfigurationChange) + .receive(on: DispatchQueue.main) + .sink { _ in + Task { @MainActor in + guard let manager = self.internalManager else { + return + } + + do { + try await manager.loadFromPreferences() + + if manager.connection.status == .invalid { + self.clearInternalManager() + } + } catch { + self.clearInternalManager() + } + } + } + .store(in: &cancellables) + } + // MARK: - Observing Status Changes private func subscribeToStatusChanges() { diff --git a/DuckDuckGoTests/MockDependencyProvider.swift b/DuckDuckGoTests/MockDependencyProvider.swift index 7a0a327087..214f675820 100644 --- a/DuckDuckGoTests/MockDependencyProvider.swift +++ b/DuckDuckGoTests/MockDependencyProvider.swift @@ -46,6 +46,7 @@ class MockDependencyProvider: DependencyProvider { var networkProtectionKeychainTokenStore: NetworkProtectionKeychainTokenStore var networkProtectionTunnelController: NetworkProtectionTunnelController var connectionObserver: NetworkProtection.ConnectionStatusObserver + var serverInfoObserver: NetworkProtection.ConnectionServerInfoObserver var vpnSettings: NetworkProtection.VPNSettings init() { @@ -88,6 +89,7 @@ class MockDependencyProvider: DependencyProvider { accountManager: accountManager) connectionObserver = ConnectionStatusObserverThroughSession() + serverInfoObserver = ConnectionServerInfoObserverThroughSession() vpnSettings = VPNSettings(defaults: .networkProtectionGroupDefaults) } } From b908108e297298e006d6e412a6d6523cd0b318d9 Mon Sep 17 00:00:00 2001 From: Daniel Bernal Date: Sat, 20 Jul 2024 14:01:41 +0200 Subject: [PATCH 09/24] [Duckplayer] 8. Age restricted videos (#3111) Task/Issue URL: https://app.asana.com/0/0/1207791658630142/f Description: Implements restricted video link handling --- .../DuckPlayerNavigationHandler.swift | 86 +++++++++++++------ DuckDuckGo/TabViewController.swift | 3 + 2 files changed, 61 insertions(+), 28 deletions(-) diff --git a/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift b/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift index bf93b4dd01..673954ef01 100644 --- a/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift +++ b/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift @@ -43,6 +43,7 @@ final class DuckPlayerNavigationHandler { static let httpMethod = "GET" static let watchInYoutubePath = "openInYoutube" static let watchInYoutubeVideoParameter = "v" + static let urlInternalReferrer = "embeds_referring_euri" } init(duckPlayer: DuckPlayerProtocol) { @@ -96,6 +97,20 @@ final class DuckPlayerNavigationHandler { performNavigation(duckPlayerRequest, responseHTML: html, webView: webView) } + func hasEmbedsReferringEuriParameter(urlString: String) -> Bool { + guard let url = URL(string: urlString), + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { + return false + } + + for queryItem in queryItems where queryItem.name == Constants.urlInternalReferrer { + return true + } + + return false + } + } extension DuckPlayerNavigationHandler: DuckNavigationHandling { @@ -107,10 +122,18 @@ extension DuckPlayerNavigationHandler: DuckNavigationHandling { os_log("DP: Handling DuckPlayer Player Navigation for %s", log: .duckPlayerLog, type: .debug, navigationAction.request.url?.absoluteString ?? "") + guard let url = navigationAction.request.url else { return } + + // Handle Youtube internal links like "Age restricted" and "Copyright restricted" videos + // These should not be handled by DuckPlayer + if url.isYoutubeVideo, + hasEmbedsReferringEuriParameter(urlString: url.absoluteString) { + return + } + // Handle Open in Youtube Links // duck://player/openInYoutube?v=12345 - if let url = navigationAction.request.url, - url.scheme == "duck" { + if url.scheme == "duck" { let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) if urlComponents?.path == "/\(Constants.watchInYoutubePath)", @@ -127,8 +150,7 @@ extension DuckPlayerNavigationHandler: DuckNavigationHandling { } // Daily Unique View Pixel - if let url = navigationAction.request.url, - url.isDuckPlayer, + if url.isDuckPlayer, duckPlayer.settings.mode != .disabled { let setting = duckPlayer.settings.mode == .enabled ? Constants.duckPlayerAlwaysString : Constants.duckPlayerDefaultString DailyPixel.fire(pixel: Pixel.Event.duckPlayerDailyUniqueView, withAdditionalParameters: [Constants.settingsKey: setting]) @@ -141,8 +163,7 @@ extension DuckPlayerNavigationHandler: DuckNavigationHandling { } // If DuckPlayer is Enabled or in ask mode, render the video - if let url = navigationAction.request.url, - url.isDuckURLScheme, + if url.isDuckURLScheme, duckPlayer.settings.mode == .enabled || duckPlayer.settings.mode == .alwaysAsk, !isDuckPlayerTemporarilyDisabled { let newRequest = Self.makeDuckPlayerRequest(from: URLRequest(url: url)) @@ -154,8 +175,7 @@ extension DuckPlayerNavigationHandler: DuckNavigationHandling { } // DuckPlayer is disabled, so we redirect to the video in YouTube - if let url = navigationAction.request.url, - let (videoID, timestamp) = url.youtubeVideoParams, + if let (videoID, timestamp) = url.youtubeVideoParams, duckPlayer.settings.mode == .disabled { os_log("DP: is Disabled. We should load original video for %s", log: .duckPlayerLog, type: .debug) handleURLChange(url: URL.youtube(videoID, timestamp: timestamp), webView: webView) @@ -167,17 +187,25 @@ extension DuckPlayerNavigationHandler: DuckNavigationHandling { // such as changes triggered via JS @MainActor func handleURLChange(url: URL?, webView: WKWebView) { + + guard let url else { return } + + // Handle Youtube internal links like "Age restricted" and "Copyright restricted" videos + // These should not be handled by DuckPlayer + if url.isYoutubeVideo, + hasEmbedsReferringEuriParameter(urlString: url.absoluteString) { + return + } // Do not handle the URL if the video was just handled - if let url = url, - url.isYoutubeVideo || url.isDuckPlayer, + if url.isYoutubeVideo || url.isDuckPlayer, let (videoID, _) = url.youtubeVideoParams, lastHandledVideoID == videoID, !isDuckPlayerTemporarilyDisabled { return } - if let url = url, url.isYoutubeVideo, + if url.isYoutubeVideo, !url.isDuckPlayer, let (videoID, timestamp) = url.youtubeVideoParams, duckPlayer.settings.mode == .enabled || duckPlayer.settings.mode == .alwaysAsk { @@ -211,10 +239,18 @@ extension DuckPlayerNavigationHandler: DuckNavigationHandling { func handleDecidePolicyFor(_ navigationAction: WKNavigationAction, webView: WKWebView) { + guard let url = navigationAction.request.url else { return } + + // Handle Youtube internal links like "Age restricted" and "Copyright restricted" videos + // These should not be handled by DuckPlayer + if url.isYoutubeVideo, + hasEmbedsReferringEuriParameter(urlString: url.absoluteString) { + return + } + // Do not handle the URL if the video was just handled - if let url = navigationAction.request.url, - url.isYoutubeVideo || url.isDuckPlayer, - let (videoID, _) = url.youtubeVideoParams, + if url.isYoutubeVideo || url.isDuckPlayer, + let (videoID, timestamp) = url.youtubeVideoParams, lastHandledVideoID == videoID, !isDuckPlayerTemporarilyDisabled { return @@ -222,25 +258,19 @@ extension DuckPlayerNavigationHandler: DuckNavigationHandling { // Pixel for Views From SERP - if let url = navigationAction.request.url, - navigationAction.request.allHTTPHeaderFields?[Constants.refererHeader] == Constants.SERPURL, + if navigationAction.request.allHTTPHeaderFields?[Constants.refererHeader] == Constants.SERPURL, duckPlayer.settings.mode == .enabled, !url.isDuckPlayer { Pixel.fire(pixel: Pixel.Event.duckPlayerViewFromSERP, debounce: 2) - } - - // Pixel for views from Other Sites - if let url = navigationAction.request.url, - navigationAction.request.allHTTPHeaderFields?[Constants.refererHeader] != Constants.SERPURL, - duckPlayer.settings.mode == .enabled, !url.isDuckPlayer { + } else { Pixel.fire(pixel: Pixel.Event.duckPlayerViewFromOther, debounce: 2) } - if let url = navigationAction.request.url, - url.isYoutubeVideo, - !url.isDuckPlayer, let (videoID, timestamp) = url.youtubeVideoParams, - duckPlayer.settings.mode == .enabled || duckPlayer.settings.mode == .alwaysAsk { - os_log("DP: Handling decidePolicy for Duck Player with %s", log: .duckPlayerLog, type: .debug, url.absoluteString) - handleURLChange(url: URL.duckPlayer(videoID, timestamp: timestamp), webView: webView) + if url.isYoutubeVideo, + !url.isDuckPlayer, + let (videoID, timestamp) = url.youtubeVideoParams, + duckPlayer.settings.mode == .enabled || duckPlayer.settings.mode == .alwaysAsk { + os_log("DP: Handling decidePolicy for Duck Player with %s", log: .duckPlayerLog, type: .debug, url.absoluteString) + handleURLChange(url: URL.duckPlayer(videoID, timestamp: timestamp), webView: webView) return } } diff --git a/DuckDuckGo/TabViewController.swift b/DuckDuckGo/TabViewController.swift index 5f3aa279e8..da5b981d51 100644 --- a/DuckDuckGo/TabViewController.swift +++ b/DuckDuckGo/TabViewController.swift @@ -408,6 +408,9 @@ class TabViewController: UIViewController { resetNavigationBar() delegate?.tabDidRequestShowingMenuHighlighter(tab: self) tabModel.viewed = true + + // Link DuckPlayer to current Tab + duckPlayerNavigationHandler.duckPlayer.setHostViewController(self) } override func buildActivities() -> [UIActivity] { From 97f6f9209ee2cd27ea04ad7b7819014a217b037b Mon Sep 17 00:00:00 2001 From: Brad Slayter Date: Mon, 22 Jul 2024 08:34:28 -0500 Subject: [PATCH 10/24] Update breakage report locale to JSON format (#3112) --- DuckDuckGo.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 5131c32908..fdf25c9c95 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -10182,7 +10182,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 174.0.0; + version = 174.0.1; }; }; 9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index cfa33a3392..32c4b77aaa 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DuckDuckGo/BrowserServicesKit", "state" : { - "revision" : "6db80afec11da4f0a36a81dc6030f7e83a524c87", - "version" : "174.0.0" + "revision" : "63195a30b02e05d1b7176c76ebc55c2cff66c9c8", + "version" : "174.0.1" } }, { From 8b30236a74bfea45ea90331710a6621c2c88e846 Mon Sep 17 00:00:00 2001 From: Michal Smaga Date: Mon, 22 Jul 2024 16:04:25 +0200 Subject: [PATCH 11/24] Allow activating subscription for internal users via debug menu (#3117) Task/Issue URL: https://app.asana.com/0/72649045549333/1207811184536020/f Description: Add to debug menu option to start the subscription activation flow. This option is to allow access to the flow even when the purchase is disallowed in given region (App Store build for non-US user). Additional changes are renaming and cleanup of obsolete items in the "Subscription" debug menu. --- DuckDuckGo.xcodeproj/project.pbxproj | 4 + DuckDuckGo/Debug.storyboard | 2 +- DuckDuckGo/MainViewController+Segues.swift | 8 ++ DuckDuckGo/SettingsRootView.swift | 11 +++ DuckDuckGo/SettingsViewModel.swift | 2 + .../SubscriptionDebugViewController.swift | 87 +++++++++++-------- .../UINavigationControllerExtension.swift | 45 ++++++++++ 7 files changed, 122 insertions(+), 37 deletions(-) create mode 100644 DuckDuckGo/UINavigationControllerExtension.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index fdf25c9c95..1812c37a6c 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -88,6 +88,7 @@ 1E908BF129827C480008C8F3 /* AutoconsentUserScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E908BEE29827C480008C8F3 /* AutoconsentUserScript.swift */; }; 1E908BF229827C480008C8F3 /* autoconsent-bundle.js in Resources */ = {isa = PBXBuildFile; fileRef = 1E908BEF29827C480008C8F3 /* autoconsent-bundle.js */; }; 1E908BF329827C480008C8F3 /* AutoconsentManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E908BF029827C480008C8F3 /* AutoconsentManagement.swift */; }; + 1E9529A12C4E748B006E80D4 /* UINavigationControllerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9529A02C4E748B006E80D4 /* UINavigationControllerExtension.swift */; }; 1EA51376286596A000493C6A /* PrivacyIconLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA51375286596A000493C6A /* PrivacyIconLogic.swift */; }; 1EA513782866039400493C6A /* TrackerAnimationLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA513772866039400493C6A /* TrackerAnimationLogic.swift */; }; 1EC458462948932500CB2B13 /* UIHostingControllerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EC458452948932500CB2B13 /* UIHostingControllerExtension.swift */; }; @@ -1247,6 +1248,7 @@ 1E908BEE29827C480008C8F3 /* AutoconsentUserScript.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutoconsentUserScript.swift; sourceTree = ""; }; 1E908BEF29827C480008C8F3 /* autoconsent-bundle.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = "autoconsent-bundle.js"; sourceTree = ""; }; 1E908BF029827C480008C8F3 /* AutoconsentManagement.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutoconsentManagement.swift; sourceTree = ""; }; + 1E9529A02C4E748B006E80D4 /* UINavigationControllerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UINavigationControllerExtension.swift; sourceTree = ""; }; 1EA51375286596A000493C6A /* PrivacyIconLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyIconLogic.swift; sourceTree = ""; }; 1EA513772866039400493C6A /* TrackerAnimationLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerAnimationLogic.swift; sourceTree = ""; }; 1EC458452948932500CB2B13 /* UIHostingControllerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIHostingControllerExtension.swift; sourceTree = ""; }; @@ -5678,6 +5680,7 @@ 319A37162829C8AD0079FBCE /* UITableViewExtension.swift */, 854A01322A558B3A00FCC628 /* UIView+Constraints.swift */, F143C32C1E4A9A4800CFDE3A /* UIViewControllerExtension.swift */, + 1E9529A02C4E748B006E80D4 /* UINavigationControllerExtension.swift */, F1DE78591E5CD2A70058895A /* UIViewExtension.swift */, F1F5337B1F26A9EF00D80D4F /* UserText.swift */, 4BBBBA912B03291700D965DA /* VPNWaitlistUserText.swift */, @@ -6855,6 +6858,7 @@ 1E8AD1D127C000AB00ABA377 /* OngoingDownloadRow.swift in Sources */, 1DEAADF02BA46E0700E25A97 /* PrivateSearchView.swift in Sources */, 85058366219AE9EA00ED4EDB /* HomePageConfiguration.swift in Sources */, + 1E9529A12C4E748B006E80D4 /* UINavigationControllerExtension.swift in Sources */, EE0153E12A6EABE0002A8B26 /* NetworkProtectionConvenienceInitialisers.swift in Sources */, C17B595B2A03AAD30055F2D1 /* PasswordGenerationPromptView.swift in Sources */, 98AA92B32456FBE100ED4B9E /* SearchFieldContainerView.swift in Sources */, diff --git a/DuckDuckGo/Debug.storyboard b/DuckDuckGo/Debug.storyboard index 46d581edff..9d6e3255c3 100644 --- a/DuckDuckGo/Debug.storyboard +++ b/DuckDuckGo/Debug.storyboard @@ -220,7 +220,7 @@ -