Skip to content

Commit

Permalink
Duck.ai deeplink (#3775)
Browse files Browse the repository at this point in the history
Task/Issue URL:
https://app.asana.com/0/1204167627774280/1208991515313413/f
Tech Design URL:
CC:

**Description**:

Add deep-link integration to duck.ai. Opening duck.ai from anywhere
should open the custom webview instead of a new tab
  • Loading branch information
Bunn authored Jan 9, 2025
1 parent 3618d2e commit b41e8e7
Show file tree
Hide file tree
Showing 7 changed files with 87 additions and 30 deletions.
3 changes: 3 additions & 0 deletions Core/FeatureFlag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ public enum FeatureFlag: String {
case autcompleteTabs
case textZoom
case adAttributionReporting
case aiChat

/// https://app.asana.com/0/72649045549333/1208231259093710/f
case networkProtectionUserTips
Expand Down Expand Up @@ -137,6 +138,8 @@ extension FeatureFlag: FeatureFlagDescribing {
return .internalOnly()
case .freeTrials:
return .remoteDevelopment(.subfeature(PrivacyProSubfeature.freeTrials))
case .aiChat:
return .remoteReleasable(.feature(.aiChat))
}
}
}
Expand Down
29 changes: 16 additions & 13 deletions DuckDuckGo/MainViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1506,27 +1506,30 @@ class MainViewController: UIViewController {
}
.store(in: &emailCancellables)
}

private func subscribeToURLInterceptorNotifications() {
NotificationCenter.default.publisher(for: .urlInterceptPrivacyPro)
.receive(on: DispatchQueue.main)
.sink { [weak self] notification in
switch notification.name {
case .urlInterceptPrivacyPro:
let deepLinkTarget: SettingsViewModel.SettingsDeepLinkSection
if let origin = notification.userInfo?[AttributionParameter.origin] as? String {
deepLinkTarget = .subscriptionFlow(origin: origin)
} else {
deepLinkTarget = .subscriptionFlow()
}
self?.launchSettings(deepLinkTarget: deepLinkTarget)
default:
return
let deepLinkTarget: SettingsViewModel.SettingsDeepLinkSection
if let origin = notification.userInfo?[AttributionParameter.origin] as? String {
deepLinkTarget = .subscriptionFlow(origin: origin)
} else {
deepLinkTarget = .subscriptionFlow()
}
self?.launchSettings(deepLinkTarget: deepLinkTarget)

}
.store(in: &urlInterceptorCancellables)

NotificationCenter.default.publisher(for: .urlInterceptAIChat)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.openAIChat()
}
.store(in: &urlInterceptorCancellables)
}

private func subscribeToSettingsDeeplinkNotifications() {
NotificationCenter.default.publisher(for: .settingsDeepLinkNotification)
.receive(on: DispatchQueue.main)
Expand Down
32 changes: 22 additions & 10 deletions DuckDuckGo/TabURLInterceptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ import Foundation
import BrowserServicesKit
import Common
import Subscription
import AIChat

enum InterceptedURL: String {
case privacyPro
case aiChat
}

struct InterceptedURLInfo {
Expand All @@ -39,31 +41,31 @@ final class TabURLInterceptorDefault: TabURLInterceptor {

typealias CanPurchaseUpdater = () -> Bool
private let canPurchase: CanPurchaseUpdater
private let featureFlagger: FeatureFlagger

init(canPurchase: @escaping CanPurchaseUpdater) {
init(featureFlagger: FeatureFlagger, canPurchase: @escaping CanPurchaseUpdater) {
self.canPurchase = canPurchase
self.featureFlagger = featureFlagger
}

static let interceptedURLs: [InterceptedURLInfo] = [
InterceptedURLInfo(id: .privacyPro, path: "/pro")
]

func allowsNavigatingTo(url: URL) -> Bool {

if !url.isPart(ofDomain: "duckduckgo.com") {
return true
if url.isDuckAIURL {
return handleURLInterception(interceptedURL: .aiChat, queryItems: nil)
}

guard let components = normalizeScheme(url.absoluteString) else {
return true
}

guard let matchingURL = urlToIntercept(path: components.path) else {

guard url.isPart(ofDomain: "duckduckgo.com"),
let components = normalizeScheme(url.absoluteString),
let matchingURL = urlToIntercept(path: components.path) else {
return true
}

return handleURLInterception(interceptedURL: matchingURL.id, queryItems: components.percentEncodedQueryItems)
}

}

extension TabURLInterceptorDefault {
Expand Down Expand Up @@ -99,11 +101,21 @@ extension TabURLInterceptorDefault {
)
return false
}
case .aiChat:
if featureFlagger.isFeatureOn(.aiChat) {
NotificationCenter.default.post(
name: .urlInterceptAIChat,
object: nil,
userInfo: nil
)
return false
}
}
return true
}
}

extension NSNotification.Name {
static let urlInterceptPrivacyPro: NSNotification.Name = Notification.Name(rawValue: "com.duckduckgo.notification.urlInterceptPrivacyPro")
static let urlInterceptAIChat: NSNotification.Name = Notification.Name(rawValue: "com.duckduckgo.notification.urlInterceptAIChat")
}
8 changes: 5 additions & 3 deletions DuckDuckGo/TabViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,7 @@ class TabViewController: UIViewController {

private var trackersInfoWorkItem: DispatchWorkItem?

private var tabURLInterceptor: TabURLInterceptor = TabURLInterceptorDefault {
return AppDependencyProvider.shared.subscriptionManager.canPurchase
}
private var tabURLInterceptor: TabURLInterceptor
private var currentlyLoadedURL: URL?

private let netPConnectionObserver: ConnectionStatusObserver = AppDependencyProvider.shared.connectionObserver
Expand Down Expand Up @@ -424,6 +422,10 @@ class TabViewController: UIViewController {
self.fireproofing = fireproofing
self.websiteDataManager = websiteDataManager

self.tabURLInterceptor = TabURLInterceptorDefault(featureFlagger: featureFlagger) {
return AppDependencyProvider.shared.subscriptionManager.canPurchase
}

super.init(coder: aDecoder)

// Assign itself as tabNavigationHandler for DuckPlayer
Expand Down
34 changes: 31 additions & 3 deletions DuckDuckGoTests/TabURLInterceptorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,7 @@ class TabURLInterceptorDefaultTests: XCTestCase {

override func setUp() {
super.setUp()
urlInterceptor = TabURLInterceptorDefault(canPurchase: {
true
})
urlInterceptor = TabURLInterceptorDefault(featureFlagger: MockFeatureFlagger(), canPurchase: { true })
}

override func tearDown() {
Expand Down Expand Up @@ -98,4 +96,34 @@ class TabURLInterceptorDefaultTests: XCTestCase {
waitForExpectations(timeout: 1)
XCTAssertNil(capturedNotification?.userInfo?[AttributionParameter.origin] as? String)
}

func testAllowsNavigationForNonAIChatURL() {
let url = URL(string: "https://www.example.com")!
XCTAssertTrue(urlInterceptor.allowsNavigatingTo(url: url))
}

func testNotificationForInterceptedAIChatPathWhenFeatureFlagIsOn() {
urlInterceptor = TabURLInterceptorDefault(featureFlagger: MockFeatureFlagger(enabledFeatureFlags: [.aiChat]), canPurchase: { true })

_ = self.expectation(forNotification: .urlInterceptAIChat, object: nil, handler: nil)

let url = URL(string: "https://duckduckgo.com/?ia=chat")!
let canNavigate = urlInterceptor.allowsNavigatingTo(url: url)

XCTAssertFalse(canNavigate)

waitForExpectations(timeout: 1) { error in
if let error = error {
XCTFail("Notification expectation failed: \(error)")
}
}
}

func testAllowsNavigationForAIChatPathWhenFeatureFlagIsOff() {
urlInterceptor = TabURLInterceptorDefault(featureFlagger: MockFeatureFlagger(enabledFeatureFlags: []), canPurchase: { true })

let url = URL(string: "https://duckduckgo.com/?ia=chat")!
XCTAssertTrue(urlInterceptor.allowsNavigatingTo(url: url))
}

}
2 changes: 1 addition & 1 deletion LocalPackages/AIChat/Sources/AIChat/URL+Extension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ extension URL {
return urlComponents.url ?? self
}

var isDuckAIURL: Bool {
public var isDuckAIURL: Bool {
guard let host = self.host, host == Constants.duckDuckGoHost else {
return false
}
Expand Down
9 changes: 9 additions & 0 deletions LocalPackages/AIChat/Tests/URLExtensionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ final class URLExtensionTests: XCTestCase {
static let duckDuckGoWithMissingQuery = "\(duckDuckGoDomain)/"
static let duckDuckGoDifferentQuery = "\(duckDuckGoDomain)/?ia=search"
static let duckDuckGoAdditionalQueryItems = "\(duckDuckGoDomain)/?ia=chat&other=param"
static let privacyPro = "\(duckDuckGoDomain)/privacypro"
}

func testAddingQueryItemToEmptyURL() {
Expand Down Expand Up @@ -103,6 +104,14 @@ final class URLExtensionTests: XCTestCase {
}
}

func testPrivacyProURL() {
if let url = URL(string: TestURLs.privacyPro) {
XCTAssertFalse(url.isDuckAIURL, "The URL should not be identified as a DuckDuckGo AI URL due to the domain.")
} else {
XCTFail("Failed to create URL from string.")
}
}

func testIsDuckAIURLWithInvalidDomain() {
if let url = URL(string: TestURLs.exampleWithExistingQuery) {
XCTAssertFalse(url.isDuckAIURL, "The URL should not be identified as a DuckDuckGo AI URL due to the domain.")
Expand Down

0 comments on commit b41e8e7

Please sign in to comment.