Skip to content

Commit

Permalink
Merge branch 'main' into sam/startup-logging-cleanup
Browse files Browse the repository at this point in the history
* main:
  Autofill Script performance improvements (#740)
  Update subscription and accounts cache after the confirmation of purchase completes (#746)
  Send correct platform value for App Store purchase options (#747)
  Collect extra metadata (#737)
  • Loading branch information
samsymons committed Mar 25, 2024
2 parents fca1775 + 0509e0c commit e888df9
Show file tree
Hide file tree
Showing 8 changed files with 214 additions and 25 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import Foundation
import Autofill
import Common

public protocol AutofillUserScriptSourceProvider {
var source: String { get }
Expand Down Expand Up @@ -101,15 +102,59 @@ public class DefaultAutofillSourceProvider: AutofillUserScriptSourceProvider {
}

private func buildReplacementsData() -> ProviderData? {
guard let userUnprotectedDomains = try? JSONEncoder().encode(privacyConfigurationManager.privacyConfig.userUnprotectedDomains),
guard let filteredPrivacyConfigData = filteredDataFrom(configData: privacyConfigurationManager.currentConfig,
keepingTopLevelKeys: ["features", "unprotectedTemporary"],
andSubKey: "autofill",
inTopLevelKey: "features"),
let userUnprotectedDomains = try? JSONEncoder().encode(privacyConfigurationManager.privacyConfig.userUnprotectedDomains),
let jsonProperties = try? JSONEncoder().encode(properties) else {
return nil
}
return ProviderData(privacyConfig: privacyConfigurationManager.currentConfig,

return ProviderData(privacyConfig: filteredPrivacyConfigData,
userUnprotectedDomains: userUnprotectedDomains,
userPreferences: jsonProperties)
}

/// `contentScope` only needs these properties from the privacy config, so creating a filtered version to improve performance
/// {
/// features: {
/// autofill: {
/// state: 'enabled',
/// exceptions: []
/// }
/// },
/// unprotectedTemporary: []
/// }
private func filteredDataFrom(configData: Data, keepingTopLevelKeys topLevelKeys: [String], andSubKey subKey: String, inTopLevelKey topLevelKey: String) -> Data? {
do {
if let jsonDict = try JSONSerialization.jsonObject(with: configData, options: []) as? [String: Any] {
var filteredDict = [String: Any]()

// Keep the specified top-level keys
for key in topLevelKeys {
if let value = jsonDict[key] {
filteredDict[key] = value
}
}

// Handle the nested dictionary for a specific top-level key to keep only the sub-key
if let nestedDict = jsonDict[topLevelKey] as? [String: Any],
let valueToKeep = nestedDict[subKey] {
filteredDict[topLevelKey] = [subKey: valueToKeep]
}

// Convert filtered dictionary back to Data
let filteredData = try JSONSerialization.data(withJSONObject: filteredDict, options: [])
return filteredData
}
} catch {
os_log(.debug, "Error during JSON serialization of privacy config: \(error.localizedDescription)")
}

return nil
}

public class Builder {
private var privacyConfigurationManager: PrivacyConfigurationManaging
private var properties: ContentScopeProperties
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ public actor NetworkProtectionTunnelFailureMonitor {
firstCheckSkipped = false

networkMonitor.pathUpdateHandler = { path in
callback(.networkPathChanged(path.debugDescription))
callback(.networkPathChanged(path.anonymousDescription))
}

task = Task.periodic(interval: Self.monitoringInterval) { [weak self] in
Expand Down Expand Up @@ -142,3 +142,26 @@ public actor NetworkProtectionTunnelFailureMonitor {
return [.wifi, .eth, .cellular].contains(connectionType) && path.status == .satisfied
}
}

extension Network.NWPath {
/// A description that's safe from a privacy standpoint.
///
/// Ref: https://app.asana.com/0/0/1206712493935053/1206712516729780/f
///
public var anonymousDescription: String {
var description = "NWPath("

description += "status: \(status), "

if #available(iOS 14.2, *), case .unsatisfied = status {
description += "unsatisfiedReason: \(unsatisfiedReason), "
}

description += "availableInterfaces: \(availableInterfaces), "
description += "isConstrained: \(isConstrained ? "true" : "false"), "
description += "isExpensive: \(isExpensive ? "true" : "false")"
description += ")"

return description
}
}
26 changes: 15 additions & 11 deletions Sources/Subscription/AccountManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public extension Notification.Name {
static let accountDidSignIn = Notification.Name("com.duckduckgo.subscription.AccountDidSignIn")
static let accountDidSignOut = Notification.Name("com.duckduckgo.subscription.AccountDidSignOut")
static let entitlementsDidChange = Notification.Name("com.duckduckgo.subscription.EntitlementsDidChange")
static let subscriptionDidChange = Notification.Name("com.duckduckgo.subscription.SubscriptionDidChange")
}

public protocol AccountManagerKeychainAccessDelegate: AnyObject {
Expand Down Expand Up @@ -245,20 +246,10 @@ public class AccountManager: AccountManaging {
return .failure(EntitlementsError.noAccessToken)
}

let cachedEntitlements: [Entitlement] = entitlementsCache.get() ?? []

switch await AuthService.validateToken(accessToken: accessToken) {
case .success(let response):
let entitlements = response.account.entitlements

if entitlements != cachedEntitlements {
if entitlements.isEmpty {
entitlementsCache.reset()
} else {
entitlementsCache.set(entitlements)
}
NotificationCenter.default.post(name: .entitlementsDidChange, object: self, userInfo: [UserDefaultsCacheKey.subscriptionEntitlements: entitlements])
}
updateCache(with: entitlements)
return .success(entitlements)

case .failure(let error):
Expand All @@ -267,6 +258,19 @@ public class AccountManager: AccountManaging {
}
}

public func updateCache(with entitlements: [Entitlement]) {
let cachedEntitlements: [Entitlement] = entitlementsCache.get() ?? []

if entitlements != cachedEntitlements {
if entitlements.isEmpty {
entitlementsCache.reset()
} else {
entitlementsCache.set(entitlements)
}
NotificationCenter.default.post(name: .entitlementsDidChange, object: self, userInfo: [UserDefaultsCacheKey.subscriptionEntitlements: entitlements])
}
}

public func fetchEntitlements(cachePolicy: CachePolicy = .returnCacheDataElseLoad) async -> Result<[Entitlement], Error> {

switch cachePolicy {
Expand Down
17 changes: 14 additions & 3 deletions Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,15 @@ public final class AppStorePurchaseFlow {

let features = SubscriptionFeatureName.allCases.map { SubscriptionFeature(name: $0.rawValue) }

return .success(SubscriptionOptions(platform: SubscriptionPlatformName.macos.rawValue,
let platform: SubscriptionPlatformName

#if os(iOS)
platform = .ios
#else
platform = .macos
#endif

return .success(SubscriptionOptions(platform: platform.rawValue,
options: options,
features: features))
}
Expand Down Expand Up @@ -122,12 +130,15 @@ public final class AppStorePurchaseFlow {
SubscriptionService.signOut()

os_log(.info, log: .subscription, "[AppStorePurchaseFlow] completeSubscriptionPurchase")
let accountManager = AccountManager(subscriptionAppGroup: subscriptionAppGroup)

guard let accessToken = AccountManager(subscriptionAppGroup: subscriptionAppGroup).accessToken else { return .failure(.missingEntitlements) }
guard let accessToken = accountManager.accessToken else { return .failure(.missingEntitlements) }

let result = await callWithRetries(retry: 5, wait: 2.0) {
switch await SubscriptionService.confirmPurchase(accessToken: accessToken, signature: transactionJWS) {
case .success:
case .success(let confirmation):
SubscriptionService.updateCache(with: confirmation.subscription)
accountManager.updateCache(with: confirmation.entitlements)
return true
case .failure:
return false
Expand Down
9 changes: 8 additions & 1 deletion Sources/Subscription/Flows/PurchaseFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ public struct SubscriptionOptions: Encodable {
let features: [SubscriptionFeature]
public static var empty: SubscriptionOptions {
let features = SubscriptionFeatureName.allCases.map { SubscriptionFeature(name: $0.rawValue) }
return SubscriptionOptions(platform: "macos", options: [], features: features)
let platform: SubscriptionPlatformName
#if os(iOS)
platform = .ios
#else
platform = .macos
#endif
return SubscriptionOptions(platform: platform.rawValue, options: [], features: features)
}
}

Expand Down Expand Up @@ -55,6 +61,7 @@ public enum SubscriptionFeatureName: String, CaseIterable {
}

public enum SubscriptionPlatformName: String {
case ios
case macos
case stripe
}
Expand Down
4 changes: 3 additions & 1 deletion Sources/Subscription/Services/Model/Subscription.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@

import Foundation

public struct Subscription: Codable {
public typealias DDGSubscription = Subscription // to avoid conflicts when Combine is imported

public struct Subscription: Codable, Equatable {
public let productId: String
public let name: String
public let billingPeriod: BillingPeriod
Expand Down
16 changes: 13 additions & 3 deletions Sources/Subscription/Services/SubscriptionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,25 @@ public final class SubscriptionService: APIService {

switch result {
case .success(let subscriptionResponse):
let defaultExpiryDate = Date().addingTimeInterval(subscriptionCache.settings.defaultExpirationInterval)
let expiryDate = min(defaultExpiryDate, subscriptionResponse.expiresOrRenewsAt)
subscriptionCache.set(subscriptionResponse, expires: expiryDate)
updateCache(with: subscriptionResponse)
return .success(subscriptionResponse)
case .failure(let error):
return .failure(.apiError(error))
}
}

public static func updateCache(with subscription: Subscription) {
let cachedSubscription: Subscription? = subscriptionCache.get()

if subscription != cachedSubscription {
let defaultExpiryDate = Date().addingTimeInterval(subscriptionCache.settings.defaultExpirationInterval)
let expiryDate = min(defaultExpiryDate, subscription.expiresOrRenewsAt)

subscriptionCache.set(subscription, expires: expiryDate)
NotificationCenter.default.post(name: .subscriptionDidChange, object: self, userInfo: [UserDefaultsCacheKey.subscription: subscription])
}
}

public static func getSubscription(accessToken: String, cachePolicy: CachePolicy = .returnCacheDataElseLoad) async -> Result<Subscription, SubscriptionServiceError> {

switch cachePolicy {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,65 @@ final class AutofillUserScriptSourceProviderTests: XCTestCase {
{
"features": {
"autofill": {
"status": "enabled",
"exceptions": []
"state": "enabled",
"features": {
"credentialsSaving": {
"state": "enabled",
"minSupportedVersion": "7.74.0"
},
"credentialsAutofill": {
"state": "enabled",
"minSupportedVersion": "7.74.0"
},
"inlineIconCredentials": {
"state": "enabled",
"minSupportedVersion": "7.74.0"
},
"accessCredentialManagement": {
"state": "enabled",
"minSupportedVersion": "7.74.0"
},
"autofillPasswordGeneration": {
"state": "enabled",
"minSupportedVersion": "7.75.0"
},
"onByDefault": {
"state": "enabled",
"minSupportedVersion": "7.93.0",
"rollout": {
"steps": [
{
"percent": 1
},
{
"percent": 10
},
{
"percent": 100
}
]
}
}
},
"hash": "ffaa2e81fb2bf264cb5ce2dadac549e1"
},
"contentBlocking": {
"state": "enabled",
"exceptions": [
{
"domain": "test-domain.com"
}
],
"hash": "910e25ffe4d683b3c708a1578d097a16"
},
"voiceSearch": {
"exceptions": [],
"state": "disabled",
"hash": "728493ef7a1488e4781656d3f9db84aa"
}
},
"unprotectedTemporary": []
"unprotectedTemporary": [],
"unprotectedOtherKey": []
}
""".data(using: .utf8)!
lazy var privacyConfig = AutofillTestHelper.preparePrivacyConfig(embeddedConfig: embeddedConfig)
Expand All @@ -53,4 +107,37 @@ final class AutofillUserScriptSourceProviderTests: XCTestCase {
XCTAssertNotNil(runtimeConfiguration)
XCTAssertFalse(runtimeConfiguration!.isEmpty)
}

func testWhenBuildRuntimeConfigurationThenContentScopeContainsRequiredAutofillKeys() throws {
let runtimeConfiguration = DefaultAutofillSourceProvider.Builder(privacyConfigurationManager: privacyConfig,
properties: properties)
.build()
.buildRuntimeConfigResponse()

let jsonData = runtimeConfiguration!.data(using: .utf8)!
let json = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any]
let success = json?["success"] as? [String: Any]
let contentScope = success?["contentScope"] as? [String: Any]
let features = contentScope?["features"] as? [String: Any]
XCTAssertNotNil(features?["autofill"] as? [String: Any])
XCTAssertNotNil(contentScope?["unprotectedTemporary"] as? [Any])
XCTAssertNil(features?["contentBlocking"])
}

func testWhenBuildRuntimeConfigurationThenContentScopeDoesNotContainUnnecessaryKeys() throws {
let runtimeConfiguration = DefaultAutofillSourceProvider.Builder(privacyConfigurationManager: privacyConfig,
properties: properties)
.build()
.buildRuntimeConfigResponse()

let jsonData = runtimeConfiguration!.data(using: .utf8)!
let json = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any]
let success = json?["success"] as? [String: Any]
let contentScope = success?["contentScope"] as? [String: Any]
XCTAssertNil(contentScope?["unprotectedOtherKey"])

let features = contentScope?["features"] as? [String: Any]
XCTAssertNil(features?["contentBlocking"])
XCTAssertNil(features?["voiceSearch"])
}
}

0 comments on commit e888df9

Please sign in to comment.