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

chore: Implement UI display with manual scroll for Carousel View (RMCCX-7619) #340

Merged
merged 19 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
71483d9
chore: Implement UI display with manual scroll for Carousel View (RMC…
SoumenRautray Dec 9, 2024
2e02f51
chore: address review comments (RMCCX-7619)
SoumenRautray Dec 9, 2024
1eb1da8
chore: fix sonar cloud issues (RMCCX-7619)
SoumenRautray Dec 9, 2024
7f3b731
chore: refactor code to reduce cognitive complexity (RMCCX-7619)
SoumenRautray Dec 9, 2024
37b8a44
chore: fix UI tests (RMCCX-7619)
SoumenRautray Dec 10, 2024
0ca35a7
chore: fix sonar cloud error and uitest failure (RMCCX-7619)
SoumenRautray Dec 10, 2024
56935ca
fix: bitrise errors (RMCCX-7619)
SoumenRautray Dec 10, 2024
2d87151
chore: change macos version in github workflow
SoumenRautray Dec 11, 2024
65c96fa
chore: fix github issues (RMCCX-7619)
SoumenRautray Dec 11, 2024
91b5bac
chore: refactor code and address review comments (RMCCX-7619)
SoumenRautray Dec 13, 2024
2984c85
chore: fix sonar cloud issues (RMCCX-7619)
SoumenRautray Dec 13, 2024
38ac2f6
chore: add tests (RMCCX-7619)
SoumenRautray Dec 13, 2024
178dc91
chore: add tests (RMCCX-7619)
SoumenRautray Dec 15, 2024
8008963
chore: increase test coverage (RMCCX-7619)
SoumenRautray Dec 17, 2024
792fc43
chore: refactor code (RMCCX-7619)
SoumenRautray Dec 17, 2024
4fddba0
chore: refactor code and fix alignment (RMCCX-7619)
SoumenRautray Dec 18, 2024
7ce15bf
chore: refactor tests for campaign dispatcher (RMCCX-7619)
SoumenRautray Dec 19, 2024
61c08a7
chore: remove deleted file from xcodeproj (RMCCX-7619)
SoumenRautray Dec 19, 2024
ef54bd3
chore: fix bitrise failure (RMCCX-7619)
SoumenRautray Dec 19, 2024
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
8 changes: 4 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ permissions:

jobs:
build-test:
runs-on: macOS-12
runs-on: macOS-14
steps:
- uses: actions/checkout@v3
with:
Expand All @@ -24,13 +24,13 @@ jobs:
-workspace RInAppMessaging.xcworkspace \
-scheme UITests \
-resultBundlePath artifacts/ui-tests/UITests \
-destination "platform=iOS Simulator,name=iPhone 11" \
-destination "platform=iOS Simulator,name=iPhone 16" \
test | xcpretty
xcodebuild \
-workspace RInAppMessaging.xcworkspace \
-scheme RInAppMessaging-Example \
-resultBundlePath artifacts/unit-tests/RInAppMessaging-Example \
-destination "platform=iOS Simulator,name=iPhone 11" \
-destination "platform=iOS Simulator,name=iPhone 16" \
test | xcpretty
- uses: kishikawakatsumi/xcresulttool@v1
with:
Expand All @@ -40,4 +40,4 @@ jobs:
upload-bundles: false
if: success() || failure()
# ^ This is important because the action will be run
# even if the test fails in the previous step.
# even if the test fails in the previous step.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
## Changelog

### Unreleased
- Features:
- Implement data model for Custom Json carousel data [RMCCX-7616]
- Implement Carousel UI display with manual scroll [RMCCX-7619]

### 9.0.0 (2024-10-22)
- Features:
- Implement Clickable image through CustomJson [RMCCX-7233]
Expand Down
8 changes: 8 additions & 0 deletions RInAppMessaging.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@
E686BF522A2692CD00D4E3E9 /* TooltipViewSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = E686BF512A2692CD00D4E3E9 /* TooltipViewSpec.swift */; };
E686BF562A2692DD00D4E3E9 /* tooltip-data.json in Resources */ = {isa = PBXBuildFile; fileRef = E686BF532A2692DD00D4E3E9 /* tooltip-data.json */; };
E6D762532CA2A87F00FE62D2 /* CampaignDataModelSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D762522CA2A87A00FE62D2 /* CampaignDataModelSpec.swift */; };
E6D77EEA2D094D1100E34FC3 /* CarouselCellSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D77EE92D094D0900E34FC3 /* CarouselCellSpec.swift */; };
E6F12B632D0C452400B4581B /* CarouselDataSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6F12B622D0C451C00B4581B /* CarouselDataSpec.swift */; };
FC1872382A49B7C0005EB10B /* UserInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC1872372A49B7C0005EB10B /* UserInfoView.swift */; };
FC18723A2A49B7D3005EB10B /* UserInfoHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC1872392A49B7D3005EB10B /* UserInfoHelper.swift */; };
FC18723C2A4E97A6005EB10B /* ConstantSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC18723B2A4E97A6005EB10B /* ConstantSpec.swift */; };
Expand Down Expand Up @@ -254,6 +256,8 @@
E686BF512A2692CD00D4E3E9 /* TooltipViewSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TooltipViewSpec.swift; sourceTree = "<group>"; };
E686BF532A2692DD00D4E3E9 /* tooltip-data.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "tooltip-data.json"; sourceTree = "<group>"; };
E6D762522CA2A87A00FE62D2 /* CampaignDataModelSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CampaignDataModelSpec.swift; sourceTree = "<group>"; };
E6D77EE92D094D0900E34FC3 /* CarouselCellSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarouselCellSpec.swift; sourceTree = "<group>"; };
E6F12B622D0C451C00B4581B /* CarouselDataSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarouselDataSpec.swift; sourceTree = "<group>"; };
FC1872372A49B7C0005EB10B /* UserInfoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserInfoView.swift; sourceTree = "<group>"; };
FC1872392A49B7D3005EB10B /* UserInfoHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserInfoHelper.swift; sourceTree = "<group>"; };
FC18723B2A4E97A6005EB10B /* ConstantSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConstantSpec.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -464,10 +468,12 @@
4557A8B0257E544C00C9D241 /* AccountRepositorySpec.swift */,
E6D762522CA2A87A00FE62D2 /* CampaignDataModelSpec.swift */,
C9827557262C423C00476505 /* BackoffSpec.swift */,
E6D77EE92D094D0900E34FC3 /* CarouselCellSpec.swift */,
45B3CC8327871C01005BA3F7 /* BundleSpec.swift */,
45563B0624064D78004EAFD3 /* CampaignRepositorySpec.swift */,
45BDFD022421D311004DEA0C /* CampaignsListManagerSpec.swift */,
D92D1F2122249833008EA748 /* CampaignsValidatorSpec.swift */,
E6F12B622D0C451C00B4581B /* CarouselDataSpec.swift */,
456FE1E824288C6500304872 /* CampaignTriggerAgentSpec.swift */,
456FE1EA2428C51E00304872 /* CommonUtilitySpec.swift */,
4538BAE226282DE4009952BE /* ConfigEndpointResponseSpec.swift */,
Expand Down Expand Up @@ -864,6 +870,7 @@
45ADA542247D206B00A9E2A3 /* RouterSpec.swift in Sources */,
E6D762532CA2A87F00FE62D2 /* CampaignDataModelSpec.swift in Sources */,
456FE1E72428513200304872 /* ErrorReportableSpec.swift in Sources */,
E6F12B632D0C452400B4581B /* CarouselDataSpec.swift in Sources */,
4538BCF6262AE8FA009952BE /* UserDataCacheSpec.swift in Sources */,
45BDFD052421EE7B004DEA0C /* SharedMocks.swift in Sources */,
FCF93D8029C2D359000DB8CE /* EventTypeSpec.swift in Sources */,
Expand Down Expand Up @@ -904,6 +911,7 @@
459DE86C2407B5750002F451 /* CustomEventValidationSpec.swift in Sources */,
45563B0724064D78004EAFD3 /* CampaignRepositorySpec.swift in Sources */,
FC18723C2A4E97A6005EB10B /* ConstantSpec.swift in Sources */,
E6D77EEA2D094D1100E34FC3 /* CarouselCellSpec.swift in Sources */,
455FEBE227F214640064470D /* UIColorExtensionSpec.swift in Sources */,
456FE1EB2428C51E00304872 /* CommonUtilitySpec.swift in Sources */,
4538BAE326282DE4009952BE /* ConfigEndpointResponseSpec.swift in Sources */,
Expand Down
152 changes: 129 additions & 23 deletions Sources/RInAppMessaging/CampaignDispatcher.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Foundation
import UIKit

internal protocol CampaignDispatcherDelegate: AnyObject {
func performPing()
Expand All @@ -24,6 +24,14 @@ internal class CampaignDispatcher: CampaignDispatcherType, TaskSchedulable {
private let dispatchQueue = DispatchQueue(label: "IAM.CampaignDisplay", qos: .userInteractive)
private(set) var queuedCampaignIDs = [String]()
private(set) var isDispatching = false

private let urlCache: URLCache = {
// response must be <= 5% of mem/disk cap in order to committed to cache
let cache = URLCache(memoryCapacity: URLCache.shared.memoryCapacity,
diskCapacity: 100 * 1024 * 1024, // fits up to 5MB images
diskPath: "RInAppMessaging")
Esakkiraja-Pothikannan marked this conversation as resolved.
Show resolved Hide resolved
return cache
}()

weak var delegate: CampaignDispatcherDelegate?
var scheduledTask: DispatchWorkItem?
Expand All @@ -40,12 +48,7 @@ internal class CampaignDispatcher: CampaignDispatcherType, TaskSchedulable {
let sessionConfig = URLSessionConfiguration.default
sessionConfig.timeoutIntervalForRequest = Constants.CampaignMessage.imageRequestTimeoutSeconds
sessionConfig.timeoutIntervalForResource = Constants.CampaignMessage.imageResourceTimeoutSeconds
sessionConfig.waitsForConnectivity = true
sessionConfig.urlCache = URLCache(
// response must be <= 5% of mem/disk cap in order to committed to cache
memoryCapacity: URLCache.shared.memoryCapacity,
diskCapacity: 100*1024*1024, // fits up to 5MB images
diskPath: "RInAppMessaging")
sessionConfig.urlCache = urlCache
httpSession = URLSession(configuration: sessionConfig)
}

Expand Down Expand Up @@ -100,28 +103,31 @@ internal class CampaignDispatcher: CampaignDispatcherType, TaskSchedulable {
dispatchNext()
return
}

// fetch from imageUrl, display if successful, skip on error
if let resImgUrlString = campaign.data.messagePayload.resource.imageUrl, let resImgUrl = URL(string: resImgUrlString) {
data(from: resImgUrl) { imgBlob in
self.dispatchQueue.async {
guard let imgBlob = imgBlob else {
self.dispatchNext()
return
}
self.displayCampaign(campaign, imageBlob: imgBlob)
}
fetchCampaignImagesAndDisplay(campaign: campaign)
}

func fetchCampaignImagesAndDisplay(campaign: Campaign) {
if let carouselData = campaign.data.customJson?.carousel,
!(carouselData.images?.isEmpty ?? true) {
Esakkiraja-Pothikannan marked this conversation as resolved.
Show resolved Hide resolved
fetchImagesArray(from: carouselData) { images in
guard let carouselData = carouselData.images else { return }
let carouselHandler = CarouselModelHandler(data: carouselData, images: images)
self.displayCampaign(campaign, carouselData: carouselHandler.getImageDataList())
}
} else {
// If no image expected, just display the message.
displayCampaign(campaign)
guard let resImgUrlString = campaign.data.messagePayload.resource.imageUrl,
let resImgUrl = URL(string: resImgUrlString) else {
// If no image expected, just display the message.
displayCampaign(campaign)
return
}
fetchImage(from: resImgUrl, for: campaign)
}
}

private func displayCampaign(_ campaign: Campaign, imageBlob: Data? = nil) {
private func displayCampaign(_ campaign: Campaign, imageBlob: Data? = nil, carouselData: [CarouselData]? = nil) {
let campaignTitle = campaign.data.messagePayload.title

router.displayCampaign(campaign, associatedImageData: imageBlob, confirmation: {
router.displayCampaign(campaign, associatedImageData: imageBlob, carouselData: carouselData, confirmation: {
let contexts = campaign.contexts
guard let delegate = self.delegate, !contexts.isEmpty, !campaign.data.isTest else {
return true
Expand Down Expand Up @@ -157,6 +163,93 @@ internal class CampaignDispatcher: CampaignDispatcherType, TaskSchedulable {
private func delayBeforeNextMessage(for campaignData: CampaignData) -> Int {
campaignData.intervalBetweenDisplaysInMS
}
}


extension CampaignDispatcher {
func fetchImagesArray(from carousel: Carousel, completion: @escaping ([UIImage?]) -> Void) {
guard let imageDetails = carousel.images else {
completion([])
return
}

let filteredDetails = imageDetails
.sorted { $0.key < $1.key }
.prefix(Constants.CampaignMessage.carouselThreshold)
.map { $0 }
Esakkiraja-Pothikannan marked this conversation as resolved.
Show resolved Hide resolved

let dispatchGroup = DispatchGroup()
var images: [UIImage?] = Array(repeating: nil, count: filteredDetails.count)

for (index, detail) in filteredDetails.enumerated() {
guard let urlString = detail.value.imgUrl else {
images[index] = nil
continue
}
dispatchGroup.enter()
fetchCarouselImage(for: urlString) { image in
images[index] = image
dispatchGroup.leave()
}
Esakkiraja-Pothikannan marked this conversation as resolved.
Show resolved Hide resolved
}
dispatchGroup.notify(queue: .main) {
completion(images)
}
}

func fetchCarouselImage(for urlString: String, completion: @escaping (UIImage?) -> Void) {
guard let url = URL(string: urlString),
["jpg", "jpeg", "png"].contains(url.pathExtension.lowercased()) else {
completion(nil)
return
}

if let cachedImage = loadImageFromCache(for: url) {
completion(cachedImage)
return
}

imageData(from: url) { data, response, error in
if let data = data, let response = response, error == nil,
let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200,
let image = UIImage(data: data) {
self.cacheImage(data: data, for: url)
completion(image)
Esakkiraja-Pothikannan marked this conversation as resolved.
Show resolved Hide resolved
} else {
completion(nil)
}
}
}

private func loadImageFromCache(for url: URL) -> UIImage? {
let request = URLRequest(url: url)
if let cachedResponse = URLCache.shared.cachedResponse(for: request) {
return UIImage(data: cachedResponse.data)
}
return nil
}

private func cacheImage(data: Data, for url: URL) {
let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!
let cachedData = CachedURLResponse(response: response, data: data)
URLCache.shared.storeCachedResponse(cachedData, for: URLRequest(url: url))
}

private func imageData(from url: URL, completion: @escaping (Data? ,URLResponse?, Error?) -> Void) {

var request = URLRequest(url: url)
request.cachePolicy = .useProtocolCachePolicy

if let cachedResponse = URLCache.shared.cachedResponse(for: request) {
completion(cachedResponse.data, cachedResponse.response, nil)
return
}

httpSession.dataTask(with: request) { (data, response, error) in
completion(data, response, error)
}.resume()
}

private func data(from url: URL, completion: @escaping (Data?) -> Void) {
httpSession.dataTask(with: URLRequest(url: url)) { (data, _, error) in
Expand All @@ -167,4 +260,17 @@ internal class CampaignDispatcher: CampaignDispatcherType, TaskSchedulable {
completion(data)
}.resume()
}

private func fetchImage(from url: URL, for campaign: Campaign) {
data(from: url) { imgBlob in
self.dispatchQueue.async {
guard let imgBlob = imgBlob else {
self.dispatchNext()
return
}
self.displayCampaign(campaign, imageBlob: imgBlob)
}
}
}

}
6 changes: 6 additions & 0 deletions Sources/RInAppMessaging/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ internal enum Constants {
enum CampaignMessage {
static let imageRequestTimeoutSeconds: TimeInterval = 20
static let imageResourceTimeoutSeconds: TimeInterval = 300
static let carouselThreshold: Int = 5
Esakkiraja-Pothikannan marked this conversation as resolved.
Show resolved Hide resolved
}

enum Request {
Expand Down Expand Up @@ -102,4 +103,9 @@ internal enum Constants {
}
}
}

enum Carousel {
static let minHeight = 5.0
static let defaultHeight = 250.0
}
}
32 changes: 32 additions & 0 deletions Sources/RInAppMessaging/Models/CarouselData.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import UIKit

struct CarouselData {
var image: UIImage?
var altText: String?
var link: String?
}

class CarouselModelHandler {
Esakkiraja-Pothikannan marked this conversation as resolved.
Show resolved Hide resolved
private var imageDataList: [CarouselData]

init(data: [String: ImageDetails], images: [UIImage?]) {
let sortedKeys = Array(data.keys).sorted()
var tempImageDataList: [CarouselData] = []

for (index, key) in sortedKeys.enumerated() {
guard index < images.count else { break }

let image = images[index]
let altText = data[key]?.altText
let link = data[key]?.link

tempImageDataList.append(CarouselData(image: image, altText: altText, link: link))
}

self.imageDataList = tempImageDataList
}

func getImageDataList() -> [CarouselData] {
return imageDataList
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,10 @@ struct Carousel: Codable {
case images
}

init(images: [String: ImageDetails]?) {
self.images = images
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
images = try container.decodeIfPresent([String: ImageDetails].self, forKey: .images)
Expand All @@ -177,6 +181,12 @@ struct ImageDetails: Codable {
case altText
}

init(imgUrl: String?, link: String?, altText: String?) {
self.imgUrl = imgUrl
self.altText = altText
self.link = link
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
imgUrl = try container.decodeIfPresent(String.self, forKey: .imgUrl)
Expand Down
Loading
Loading