Skip to content

Commit

Permalink
Add survey action and Privacy Pro attributes (#826)
Browse files Browse the repository at this point in the history
Task/Issue URL: https://app.asana.com/0/1193060753475688/1207234800675206/f
iOS PR: duckduckgo/iOS#2879
macOS PR: duckduckgo/macos-browser#2798
What kind of version bump will this require?: Major

Description:

This PR adds two things to RMF:

A new survey action had been added, with configurable parameters
New Privacy Pro attributes have been added
  • Loading branch information
samsymons authored May 24, 2024
1 parent e1e4364 commit 610a58a
Show file tree
Hide file tree
Showing 16 changed files with 290 additions and 91 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
.DS_Store
xcuserdata/
.vscode
*.swift.plist
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@ import Foundation

struct JsonToRemoteConfigModelMapper {

static func mapJson(remoteMessagingConfig: RemoteMessageResponse.JsonRemoteMessagingConfig) -> RemoteConfigModel {
let remoteMessages = JsonToRemoteMessageModelMapper.maps(jsonRemoteMessages: remoteMessagingConfig.messages)
static func mapJson(remoteMessagingConfig: RemoteMessageResponse.JsonRemoteMessagingConfig,
surveyActionMapper: RemoteMessagingSurveyActionMapping) -> RemoteConfigModel {
let remoteMessages = JsonToRemoteMessageModelMapper.maps(
jsonRemoteMessages: remoteMessagingConfig.messages,
surveyActionMapper: surveyActionMapper
)
os_log("remoteMessages mapped = %s", log: .remoteMessaging, type: .debug, String(describing: remoteMessages))
let rules = JsonToRemoteMessageModelMapper.maps(jsonRemoteRules: remoteMessagingConfig.rules)
os_log("rules mapped = %s", log: .remoteMessaging, type: .debug, String(describing: rules))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ private enum AttributesKey: String, CaseIterable {
case favorites
case appTheme
case daysSinceInstalled
case isNetPWaitlistUser
case daysSinceNetPEnabled
case pproEligible
case pproSubscriber

func matchingAttribute(jsonMatchingAttribute: AnyDecodable) -> MatchingAttribute {
switch self {
Expand All @@ -56,20 +57,26 @@ private enum AttributesKey: String, CaseIterable {
case .favorites: return FavoritesMatchingAttribute(jsonMatchingAttribute: jsonMatchingAttribute)
case .appTheme: return AppThemeMatchingAttribute(jsonMatchingAttribute: jsonMatchingAttribute)
case .daysSinceInstalled: return DaysSinceInstalledMatchingAttribute(jsonMatchingAttribute: jsonMatchingAttribute)
case .isNetPWaitlistUser: return IsNetPWaitlistUserMatchingAttribute(jsonMatchingAttribute: jsonMatchingAttribute)
case .daysSinceNetPEnabled: return DaysSinceNetPEnabledMatchingAttribute(jsonMatchingAttribute: jsonMatchingAttribute)
case .pproEligible: return IsPrivacyProEligibleUserMatchingAttribute(jsonMatchingAttribute: jsonMatchingAttribute)
case .pproSubscriber: return IsPrivacyProSubscriberUserMatchingAttribute(jsonMatchingAttribute: jsonMatchingAttribute)
}
}
}
// swiftlint:enable cyclomatic_complexity

struct JsonToRemoteMessageModelMapper {

static func maps(jsonRemoteMessages: [RemoteMessageResponse.JsonRemoteMessage]) -> [RemoteMessageModel] {
static func maps(jsonRemoteMessages: [RemoteMessageResponse.JsonRemoteMessage],
surveyActionMapper: RemoteMessagingSurveyActionMapping) -> [RemoteMessageModel] {
var remoteMessages: [RemoteMessageModel] = []
jsonRemoteMessages.forEach { message in
guard let content = mapToContent( content: message.content, surveyActionMapper: surveyActionMapper) else {
return
}

var remoteMessage = RemoteMessageModel(id: message.id,
content: mapToContent(content: message.content),
content: content,
matchingRules: message.matchingRules ?? [],
exclusionRules: message.exclusionRules ?? [])

Expand All @@ -83,7 +90,8 @@ struct JsonToRemoteMessageModelMapper {
}

// swiftlint:disable cyclomatic_complexity function_body_length
static func mapToContent(content: RemoteMessageResponse.JsonContent) -> RemoteMessageModelType? {
static func mapToContent(content: RemoteMessageResponse.JsonContent,
surveyActionMapper: RemoteMessagingSurveyActionMapping) -> RemoteMessageModelType? {
switch RemoteMessageResponse.JsonMessageType(rawValue: content.messageType) {
case .small:
guard !content.titleText.isEmpty, !content.descriptionText.isEmpty else {
Expand All @@ -103,7 +111,7 @@ struct JsonToRemoteMessageModelMapper {
case .bigSingleAction:
guard let primaryActionText = content.primaryActionText,
!primaryActionText.isEmpty,
let action = mapToAction(content.primaryAction)
let action = mapToAction(content.primaryAction, surveyActionMapper: surveyActionMapper)
else {
return nil
}
Expand All @@ -116,10 +124,10 @@ struct JsonToRemoteMessageModelMapper {
case .bigTwoAction:
guard let primaryActionText = content.primaryActionText,
!primaryActionText.isEmpty,
let primaryAction = mapToAction(content.primaryAction),
let primaryAction = mapToAction(content.primaryAction, surveyActionMapper: surveyActionMapper),
let secondaryActionText = content.secondaryActionText,
!secondaryActionText.isEmpty,
let secondaryAction = mapToAction(content.secondaryAction)
let secondaryAction = mapToAction(content.secondaryAction, surveyActionMapper: surveyActionMapper)
else {
return nil
}
Expand All @@ -134,7 +142,7 @@ struct JsonToRemoteMessageModelMapper {
case .promoSingleAction:
guard let actionText = content.actionText,
!actionText.isEmpty,
let action = mapToAction(content.action)
let action = mapToAction(content.action, surveyActionMapper: surveyActionMapper)
else {
return nil
}
Expand All @@ -151,7 +159,8 @@ struct JsonToRemoteMessageModelMapper {
}
// swiftlint:enable cyclomatic_complexity function_body_length

static func mapToAction(_ jsonAction: RemoteMessageResponse.JsonMessageAction?) -> RemoteAction? {
static func mapToAction(_ jsonAction: RemoteMessageResponse.JsonMessageAction?,
surveyActionMapper: RemoteMessagingSurveyActionMapping) -> RemoteAction? {
guard let jsonAction = jsonAction else {
return nil
}
Expand All @@ -161,8 +170,23 @@ struct JsonToRemoteMessageModelMapper {
return .share(value: jsonAction.value, title: jsonAction.additionalParameters?["title"])
case .url:
return .url(value: jsonAction.value)
case .surveyURL:
return .surveyURL(value: jsonAction.value)
case .survey:
if let queryParamsString = jsonAction.additionalParameters?["queryParams"] as? String {
let queryParams = queryParamsString.components(separatedBy: ";")
let mappedQueryParams = queryParams.compactMap { param in
return RemoteMessagingSurveyActionParameter(rawValue: param)
}

if mappedQueryParams.count == queryParams.count, let surveyURL = URL(string: jsonAction.value) {
let updatedURL = surveyActionMapper.add(parameters: mappedQueryParams, to: surveyURL)
return .survey(value: updatedURL.absoluteString)
} else {
// The message requires a parameter that isn't supported
return nil
}
} else {
return .survey(value: jsonAction.value)
}
case .appStore:
return .appStore
case .dismiss:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//
// RemoteMessagingSurveyActionMapping.swift
//
// Copyright © 2024 DuckDuckGo. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation

public enum RemoteMessagingSurveyActionParameter: String, CaseIterable {
case appVersion = "ddgv"
case atb = "atb"
case atbVariant = "var"
case daysInstalled = "delta"
case hardwareModel = "mo"
case lastActiveDate = "da"
case osVersion = "osv"
}

public protocol RemoteMessagingSurveyActionMapping {

func add(parameters: [RemoteMessagingSurveyActionParameter], to url: URL) -> URL

}
29 changes: 19 additions & 10 deletions Sources/RemoteMessaging/Matchers/UserAttributeMatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ public struct UserAttributeMatcher: AttributeMatcher {
private let bookmarksCount: Int
private let favoritesCount: Int
private let isWidgetInstalled: Bool
private let isNetPWaitlistUser: Bool
private let daysSinceNetPEnabled: Int
private let isPrivacyProEligibleUser: Bool
private let isPrivacyProSubscriber: Bool

public init(statisticsStore: StatisticsStore,
variantManager: VariantManager,
Expand All @@ -39,8 +40,9 @@ public struct UserAttributeMatcher: AttributeMatcher {
favoritesCount: Int,
appTheme: String,
isWidgetInstalled: Bool,
isNetPWaitlistUser: Bool,
daysSinceNetPEnabled: Int
daysSinceNetPEnabled: Int,
isPrivacyProEligibleUser: Bool,
isPrivacyProSubscriber: Bool
) {
self.statisticsStore = statisticsStore
self.variantManager = variantManager
Expand All @@ -49,8 +51,9 @@ public struct UserAttributeMatcher: AttributeMatcher {
self.bookmarksCount = bookmarksCount
self.favoritesCount = favoritesCount
self.isWidgetInstalled = isWidgetInstalled
self.isNetPWaitlistUser = isNetPWaitlistUser
self.daysSinceNetPEnabled = daysSinceNetPEnabled
self.isPrivacyProEligibleUser = isPrivacyProEligibleUser
self.isPrivacyProSubscriber = isPrivacyProSubscriber
}

// swiftlint:disable:next cyclomatic_complexity function_body_length
Expand Down Expand Up @@ -97,18 +100,24 @@ public struct UserAttributeMatcher: AttributeMatcher {
}

return BooleanMatchingAttribute(value).matches(value: isWidgetInstalled)
case let matchingAttribute as IsNetPWaitlistUserMatchingAttribute:
guard let value = matchingAttribute.value else {
return .fail
}

return BooleanMatchingAttribute(value).matches(value: isNetPWaitlistUser)
case let matchingAttribute as DaysSinceNetPEnabledMatchingAttribute:
if matchingAttribute.value != MatchingAttributeDefaults.intDefaultValue {
return IntMatchingAttribute(matchingAttribute.value).matches(value: daysSinceNetPEnabled)
} else {
return RangeIntMatchingAttribute(min: matchingAttribute.min, max: matchingAttribute.max).matches(value: daysSinceNetPEnabled)
}
case let matchingAttribute as IsPrivacyProEligibleUserMatchingAttribute:
guard let value = matchingAttribute.value else {
return .fail
}

return BooleanMatchingAttribute(value).matches(value: isPrivacyProEligibleUser)
case let matchingAttribute as IsPrivacyProSubscriberUserMatchingAttribute:
guard let value = matchingAttribute.value else {
return .fail
}

return BooleanMatchingAttribute(value).matches(value: isPrivacyProSubscriber)
default:
assertionFailure("Could not find matching attribute")
return nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ public enum RemoteMessageResponse {
case url
case appStore = "appstore"
case dismiss
case surveyURL = "survey_url"
case survey = "survey"
}

enum JsonPlaceholder: String, CaseIterable {
Expand Down
67 changes: 46 additions & 21 deletions Sources/RemoteMessaging/Model/MatchingAttributes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,45 @@ struct RangeStringNumericMatchingAttribute: Equatable {
}
}

struct IsNetPWaitlistUserMatchingAttribute: MatchingAttribute, Equatable {
struct DaysSinceNetPEnabledMatchingAttribute: MatchingAttribute, Equatable {
var min: Int = MatchingAttributeDefaults.intDefaultValue
var max: Int = MatchingAttributeDefaults.intDefaultMaxValue
var value: Int = MatchingAttributeDefaults.intDefaultValue
var fallback: Bool?

init(jsonMatchingAttribute: AnyDecodable) {
guard let jsonMatchingAttribute = jsonMatchingAttribute.value as? [String: Any] else { return }

if let min = jsonMatchingAttribute[RuleAttributes.min] as? Int {
self.min = min
}
if let max = jsonMatchingAttribute[RuleAttributes.max] as? Int {
self.max = max
}
if let value = jsonMatchingAttribute[RuleAttributes.value] as? Int {
self.value = value
}
if let fallback = jsonMatchingAttribute[RuleAttributes.fallback] as? Bool {
self.fallback = fallback
}
}

init(min: Int = MatchingAttributeDefaults.intDefaultValue,
max: Int = MatchingAttributeDefaults.intDefaultMaxValue,
value: Int = MatchingAttributeDefaults.intDefaultValue,
fallback: Bool?) {
self.min = min
self.max = max
self.value = value
self.fallback = fallback
}

static func == (lhs: DaysSinceNetPEnabledMatchingAttribute, rhs: DaysSinceNetPEnabledMatchingAttribute) -> Bool {
return lhs.min == rhs.min && lhs.max == rhs.max && lhs.value == rhs.value && lhs.fallback == rhs.fallback
}
}

struct IsPrivacyProEligibleUserMatchingAttribute: MatchingAttribute, Equatable {
var value: Bool?
var fallback: Bool?

Expand All @@ -628,46 +666,33 @@ struct IsNetPWaitlistUserMatchingAttribute: MatchingAttribute, Equatable {
self.fallback = fallback
}

static func == (lhs: IsNetPWaitlistUserMatchingAttribute, rhs: IsNetPWaitlistUserMatchingAttribute) -> Bool {
static func == (lhs: IsPrivacyProEligibleUserMatchingAttribute, rhs: IsPrivacyProEligibleUserMatchingAttribute) -> Bool {
return lhs.value == rhs.value && lhs.fallback == rhs.fallback
}
}

struct DaysSinceNetPEnabledMatchingAttribute: MatchingAttribute, Equatable {
var min: Int = MatchingAttributeDefaults.intDefaultValue
var max: Int = MatchingAttributeDefaults.intDefaultMaxValue
var value: Int = MatchingAttributeDefaults.intDefaultValue
struct IsPrivacyProSubscriberUserMatchingAttribute: MatchingAttribute, Equatable {
var value: Bool?
var fallback: Bool?

init(jsonMatchingAttribute: AnyDecodable) {
guard let jsonMatchingAttribute = jsonMatchingAttribute.value as? [String: Any] else { return }

if let min = jsonMatchingAttribute[RuleAttributes.min] as? Int {
self.min = min
}
if let max = jsonMatchingAttribute[RuleAttributes.max] as? Int {
self.max = max
}
if let value = jsonMatchingAttribute[RuleAttributes.value] as? Int {
if let value = jsonMatchingAttribute[RuleAttributes.value] as? Bool {
self.value = value
}
if let fallback = jsonMatchingAttribute[RuleAttributes.fallback] as? Bool {
self.fallback = fallback
}
}

init(min: Int = MatchingAttributeDefaults.intDefaultValue,
max: Int = MatchingAttributeDefaults.intDefaultMaxValue,
value: Int = MatchingAttributeDefaults.intDefaultValue,
fallback: Bool?) {
self.min = min
self.max = max
init(value: Bool?, fallback: Bool?) {
self.value = value
self.fallback = fallback
}

static func == (lhs: DaysSinceNetPEnabledMatchingAttribute, rhs: DaysSinceNetPEnabledMatchingAttribute) -> Bool {
return lhs.min == rhs.min && lhs.max == rhs.max && lhs.value == rhs.value && lhs.fallback == rhs.fallback
static func == (lhs: IsPrivacyProSubscriberUserMatchingAttribute, rhs: IsPrivacyProSubscriberUserMatchingAttribute) -> Bool {
return lhs.value == rhs.value && lhs.fallback == rhs.fallback
}
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/RemoteMessaging/Model/RemoteMessageModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ public enum RemoteMessageModelType: Codable, Equatable {
public enum RemoteAction: Codable, Equatable {
case share(value: String, title: String?)
case url(value: String)
case surveyURL(value: String)
case survey(value: String)
case appStore
case dismiss
}
Expand Down
3 changes: 3 additions & 0 deletions Sources/RemoteMessaging/RemoteMessagingConfigMatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,21 @@ public struct RemoteMessagingConfigMatcher {
private let userAttributeMatcher: UserAttributeMatcher
private let percentileStore: RemoteMessagingPercentileStoring
private let dismissedMessageIds: [String]
let surveyActionMapper: RemoteMessagingSurveyActionMapping

private let matchers: [AttributeMatcher]

public init(appAttributeMatcher: AppAttributeMatcher,
deviceAttributeMatcher: DeviceAttributeMatcher = DeviceAttributeMatcher(),
userAttributeMatcher: UserAttributeMatcher,
percentileStore: RemoteMessagingPercentileStoring,
surveyActionMapper: RemoteMessagingSurveyActionMapping,
dismissedMessageIds: [String]) {
self.appAttributeMatcher = appAttributeMatcher
self.deviceAttributeMatcher = deviceAttributeMatcher
self.userAttributeMatcher = userAttributeMatcher
self.percentileStore = percentileStore
self.surveyActionMapper = surveyActionMapper
self.dismissedMessageIds = dismissedMessageIds

matchers = [appAttributeMatcher, deviceAttributeMatcher, userAttributeMatcher]
Expand Down
5 changes: 4 additions & 1 deletion Sources/RemoteMessaging/RemoteMessagingConfigProcessor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ public struct RemoteMessagingConfigProcessor {
let isNewVersion = newVersion != currentVersion

if isNewVersion || shouldProcessConfig(currentConfig) {
let config = JsonToRemoteConfigModelMapper.mapJson(remoteMessagingConfig: jsonRemoteMessagingConfig)
let config = JsonToRemoteConfigModelMapper.mapJson(
remoteMessagingConfig: jsonRemoteMessagingConfig,
surveyActionMapper: remoteMessagingConfigMatcher.surveyActionMapper
)
let message = remoteMessagingConfigMatcher.evaluate(remoteConfig: config)
os_log("Message to present next: %s", log: .remoteMessaging, type: .debug, message.debugDescription)

Expand Down
Loading

0 comments on commit 610a58a

Please sign in to comment.