Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add survey action and Privacy Pro attributes #826

Merged
merged 13 commits into from
May 24, 2024
Merged
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,25 @@ 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
var remoteMessage = RemoteMessageModel(id: message.id,
samsymons marked this conversation as resolved.
Show resolved Hide resolved
content: mapToContent(content: message.content),
content: mapToContent(
content: message.content,
surveyActionMapper: surveyActionMapper
),
matchingRules: message.matchingRules ?? [],
exclusionRules: message.exclusionRules ?? [])

Expand All @@ -83,7 +89,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 +110,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 +123,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 +141,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 +158,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 +169,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
samsymons marked this conversation as resolved.
Show resolved Hide resolved
}
} 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

}
samsymons marked this conversation as resolved.
Show resolved Hide resolved
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
Loading