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

feat: Add Push Primer feature using Custom Json (RMCCX-6702) #322

Merged
merged 15 commits into from
Aug 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
## Changelog

### Unreleased
- Features:
- Added support for PushPrimer Campaign using CustomJson [RMCCX-6702]
- Prevent showing Push Priemr Campaign if Notificaation Permission was granted already [RMCCX-6707]
- Added CustomJson feature for RMC SDK users [RMCCX-6712]
- Redirect user to App Notification Permission Settings if permission was denied previously in Push Primer [RMCCX-6710]

### 8.3.0 (2024-05-13)
- Improvements:
Expand Down
33 changes: 31 additions & 2 deletions Sources/RInAppMessaging/CampaignsValidator.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import UserNotifications
import Dispatch

internal protocol CampaignsValidatorType {

/// Cross references the list of campaigns from CampaignRepository
Expand All @@ -20,12 +23,14 @@ internal struct CampaignsValidator: CampaignsValidatorType {
private let campaignRepository: CampaignRepositoryType
private let eventMatcher: EventMatcherType
private let triggerValidator = TriggerAttributesValidator.self
private let notificationCenter: RemoteNotificationRequestable

init(campaignRepository: CampaignRepositoryType,
eventMatcher: EventMatcherType) {

eventMatcher: EventMatcherType,
notificationCenter: RemoteNotificationRequestable) {
self.campaignRepository = campaignRepository
self.eventMatcher = eventMatcher
self.notificationCenter = notificationCenter
}

func validate(validatedCampaignHandler: (_ campaign: Campaign, _ events: Set<Event>) -> Void) {
Expand All @@ -38,6 +43,14 @@ internal struct CampaignsValidator: CampaignsValidatorType {
guard campaign.data.isTest || (!campaign.isOptedOut && !campaign.isOutdated) else {
return
}

// Enable PushPrimer only for RMC Sdk
if campaign.isPushPrimer {
guard RInAppMessaging.isRMCEnvironment, !isNotificationAuthorized() else {
return
}
}

guard let campaignTriggers = campaign.data.triggers else {
Logger.debug("campaign (\(campaign.id)) has no triggers.")
return
Expand Down Expand Up @@ -90,4 +103,20 @@ internal struct CampaignsValidator: CampaignsValidatorType {

return triggeredEvents
}

func isNotificationAuthorized(timeout: DispatchTime = .now() + 3) -> Bool {
var isAuthorized = false
let semaphore = DispatchSemaphore(value: 0)
notificationCenter.getAuthorizationStatus { authStatus in
if authStatus == .authorized {
isAuthorized = true
}
semaphore.signal()
}
if semaphore.wait(timeout: timeout) == .timedOut {
return false
} else {
return isAuthorized
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import UserNotifications

#if SWIFT_PACKAGE
import RSDKUtilsMain
Expand Down Expand Up @@ -98,7 +99,8 @@ internal enum MainContainerFactory {
elements.append(contentsOf: [
ContainerElement(type: CampaignsValidatorType.self, factory: {
CampaignsValidator(campaignRepository: manager.resolve(type: CampaignRepositoryType.self)!,
eventMatcher: manager.resolve(type: EventMatcherType.self)!)
eventMatcher: manager.resolve(type: EventMatcherType.self)!,
notificationCenter: UNUserNotificationCenter.current())
}, transient: true),
ContainerElement(type: FullViewPresenterType.self, factory: {
FullViewPresenter(campaignRepository: manager.resolve(type: CampaignRepositoryType.self)!,
Expand Down
27 changes: 27 additions & 0 deletions Sources/RInAppMessaging/Extensions/UIApplication+IAM.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,31 @@ extension UIApplication {
// Only iOS 13+ is supported
return nil
}

private static let notificationSettingsURL: URL? = {
var urlString: String?

if #available(iOS 16, *) {
urlString = UIApplication.openNotificationSettingsURLString
}
if #available(iOS 15.4, *) {
urlString = UIApplicationOpenNotificationSettingsURLString
}
if #available(iOS 8.0, *) {
urlString = UIApplication.openSettingsURLString
}

guard let urlString = urlString, let url = URL(string: urlString) else {
return nil
}

return url
}()

func openAppNotificationSettings() {
guard let url = Self.notificationSettingsURL else {
return
}
self.open(url)
SoumenRautray marked this conversation as resolved.
Show resolved Hide resolved
}
}
3 changes: 3 additions & 0 deletions Sources/RInAppMessaging/Models/Responses/Campaign.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ internal struct Campaign: Codable, Hashable {
String(substring.drop(while: { $0 != "["}).dropFirst())
}.filter { !$0.isEmpty }
}
var isPushPrimer: Bool {
return data.customJson?.pushPrimer?.button != nil
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ internal struct CampaignData: Codable, Hashable {
let hasNoEndDate: Bool
let isCampaignDismissable: Bool
let messagePayload: MessagePayload
let customJson: CustomJson?

var intervalBetweenDisplaysInMS: Int {
messagePayload.messageSettings.displaySettings.delay
Expand All @@ -21,7 +22,8 @@ internal struct CampaignData: Codable, Hashable {
infiniteImpressions: Bool,
hasNoEndDate: Bool,
isCampaignDismissable: Bool,
messagePayload: MessagePayload) {
messagePayload: MessagePayload,
customJson: CustomJson?) {

self.campaignId = campaignId
self.maxImpressions = maxImpressions
Expand All @@ -32,6 +34,7 @@ internal struct CampaignData: Codable, Hashable {
self.hasNoEndDate = hasNoEndDate
self.isCampaignDismissable = isCampaignDismissable
self.messagePayload = messagePayload
self.customJson = customJson
}

init(from decoder: Decoder) throws {
Expand All @@ -45,6 +48,7 @@ internal struct CampaignData: Codable, Hashable {
hasNoEndDate = try container.decodeIfPresent(Bool.self, forKey: .hasNoEndDate) ?? false
isCampaignDismissable = try container.decodeIfPresent(Bool.self, forKey: .isCampaignDismissable) ?? true
messagePayload = try container.decode(MessagePayload.self, forKey: .messagePayload)
customJson = try container.decodeIfPresent(CustomJson.self, forKey: .customJson)
}

func hash(into hasher: inout Hasher) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,37 @@ internal struct ButtonBehavior: Codable {
let action: ActionType
let uri: String?
}

internal struct CustomJson: Codable {
let pushPrimer: PrimerButton?

enum CodingKeys: String, CodingKey {
case pushPrimer
}

init(pushPrimer: PrimerButton? = nil) {
self.pushPrimer = pushPrimer
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
pushPrimer = try container.decodeIfPresent(PrimerButton.self, forKey: .pushPrimer)
}
}

internal struct PrimerButton: Codable {
let button: Int?

enum CodingKeys: String, CodingKey {
case button
}

init(button: Int? = nil) {
self.button = button
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
button = try container.decodeIfPresent(Int.self, forKey: .button)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,17 @@ internal class FullViewPresenter: BaseViewPresenter, FullViewPresenterType, Erro

func loadButtons() {
let buttonList = campaign.data.messagePayload.messageSettings.controlSettings.buttons

let pushPrimerButton = campaign.data.customJson?.pushPrimer?.button
let supportedButtons = buttonList.prefix(2).filter {
[.redirect, .deeplink, .close, .pushPrimer].contains($0.buttonBehavior.action)
}

var buttonsToAdd = [(ActionButton, ActionButtonViewModel)]()
for (index, button) in supportedButtons.enumerated() {
let buttonAction = (pushPrimerButton != nil && index + 1 == pushPrimerButton) ? ActionType.pushPrimer : button.buttonBehavior.action
let backgroundColor = UIColor(hexString: button.buttonBackgroundColor) ?? .white
buttonsToAdd.append((
ActionButton(type: button.buttonBehavior.action,
ActionButton(type: buttonAction,
impression: index == 0 ? ImpressionType.actionOne : ImpressionType.actionTwo,
uri: button.buttonBehavior.uri,
trigger: button.campaignTrigger),
Expand Down Expand Up @@ -129,6 +130,12 @@ internal class FullViewPresenter: BaseViewPresenter, FullViewPresenterType, Erro
notificationCenter.getAuthorizationStatus { [self] authorizationStatus in
// `self` becomes nil when the campaign window gets closed
let willPopupAppear = authorizationStatus == .notDetermined

if authorizationStatus == .denied {
DispatchQueue.main.async {
UIApplication.shared.openAppNotificationSettings()
}
}

self.notificationCenter.requestAuthorization(options: pushPrimerOptions) { [self] (granted, error) in
if let error = error {
Expand Down
68 changes: 65 additions & 3 deletions Tests/Tests/CampaignsValidatorSpec.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Quick
import Nimble
import UserNotifications
@testable import RInAppMessaging

class CampaignsValidatorSpec: QuickSpec {
Expand All @@ -10,18 +11,21 @@ class CampaignsValidatorSpec: QuickSpec {
var campaignRepository: CampaignRepository!
var eventMatcher: EventMatcher!
var validatorHandler: ValidatorHandler!
var mockNotificationCenter: UNUserNotificationCenterMock!

func syncRepository(with campaigns: [Campaign]) {
campaignRepository.syncWith(list: campaigns, timestampMilliseconds: 0, ignoreTooltips: false)
}

beforeEach {
mockNotificationCenter = UNUserNotificationCenterMock()
campaignRepository = CampaignRepository(userDataCache: UserDataCacheMock(),
accountRepository: AccountRepository(userDataCache: UserDataCacheMock()))
eventMatcher = EventMatcher(campaignRepository: campaignRepository)
campaignsValidator = CampaignsValidator(
campaignRepository: campaignRepository,
eventMatcher: eventMatcher)
eventMatcher: eventMatcher,
notificationCenter: mockNotificationCenter)
validatorHandler = ValidatorHandler()
}

Expand Down Expand Up @@ -223,6 +227,62 @@ class CampaignsValidatorSpec: QuickSpec {
expect(validatorHandler.validatedCampaigns).to(contain(tooltip))
}
}
context("when notification is authorized") {
it("returns true") {
mockNotificationCenter.authorizationStatus = .authorized
let result = campaignsValidator.isNotificationAuthorized()
expect(result).to(beTrue())
}
}

context("when notification is denied") {
it("returns false") {
mockNotificationCenter.authorizationStatus = .denied
let result = campaignsValidator.isNotificationAuthorized()
expect(result).to(beFalse())
}
}

context("when notification is not determined") {
it("returns false") {
mockNotificationCenter.authorizationStatus = .notDetermined
let result = campaignsValidator.isNotificationAuthorized()
expect(result).to(beFalse())
}
}

context("when notification is provisional") {
it("returns false") {
mockNotificationCenter.authorizationStatus = .provisional
let result = campaignsValidator.isNotificationAuthorized()
expect(result).to(beFalse())
}
}

context("when authorization status is authorized before timeout") {
it("returns true") {
mockNotificationCenter.authorizationStatus = .authorized
let result = campaignsValidator.isNotificationAuthorized(timeout: .now() + 5)
expect(result).to(beTrue())
}
}

context("when authorization status is denied before timeout") {
it("returns false") {
mockNotificationCenter.authorizationStatus = .notDetermined
let result = campaignsValidator.isNotificationAuthorized(timeout: .now() + 5)
expect(result).to(beFalse())
}
}

context("when authorization status request times out") {
it("handles the timeout correctly") {
mockNotificationCenter.authorizationStatus = .authorized
mockNotificationCenter.callCompletion = false
let result = campaignsValidator.isNotificationAuthorized(timeout: .now() + 5)
expect(result).to(beFalse())
}
}
}
}
}
Expand Down Expand Up @@ -267,7 +327,8 @@ private enum MockedCampaigns {
infiniteImpressions: false,
hasNoEndDate: false,
isCampaignDismissable: true,
messagePayload: outdatedMessagePayload
messagePayload: outdatedMessagePayload,
customJson: nil
)
)

Expand All @@ -281,7 +342,8 @@ private enum MockedCampaigns {
infiniteImpressions: false,
hasNoEndDate: false,
isCampaignDismissable: true,
messagePayload: outdatedMessagePayload
messagePayload: outdatedMessagePayload,
customJson: nil
)
)
}
5 changes: 4 additions & 1 deletion Tests/Tests/CustomEventValidationSpec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,21 @@ class CustomEventValidationSpec: QuickSpec {
var campaignRepository: CampaignRepository!
var eventMatcher: EventMatcher!
var validatorHandler: ValidatorHandler!
var mockNotificationCenter: UNUserNotificationCenterMock!

func syncRepository(with campaigns: [Campaign]) {
campaignRepository.syncWith(list: campaigns, timestampMilliseconds: 0, ignoreTooltips: false)
}

beforeEach {
mockNotificationCenter = UNUserNotificationCenterMock()
campaignRepository = CampaignRepository(userDataCache: UserDataCacheMock(),
accountRepository: AccountRepository(userDataCache: UserDataCacheMock()))
eventMatcher = EventMatcher(campaignRepository: campaignRepository)
campaignsValidator = CampaignsValidator(
campaignRepository: campaignRepository,
eventMatcher: eventMatcher)
eventMatcher: eventMatcher,
notificationCenter: mockNotificationCenter)
validatorHandler = ValidatorHandler()
}

Expand Down
5 changes: 4 additions & 1 deletion Tests/Tests/Helpers/SharedMocks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,7 @@ final class UNUserNotificationCenterMock: RemoteNotificationRequestable {
var authorizationRequestError: Error?
var authorizationGranted = true
var authorizationStatus = UNAuthorizationStatus.notDetermined
var callCompletion: Bool = true

func requestAuthorization(options: UNAuthorizationOptions = [],
completionHandler: @escaping (Bool, Error?) -> Void) {
Expand All @@ -550,7 +551,9 @@ final class UNUserNotificationCenterMock: RemoteNotificationRequestable {
}

func getAuthorizationStatus(completionHandler: @escaping (UNAuthorizationStatus) -> Void) {
completionHandler(authorizationStatus)
if callCompletion {
completionHandler(authorizationStatus)
}
}
}

Expand Down
Loading
Loading