Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -1,180 +1,133 @@
//
// DownloadManager.swift
// StripeCore
// StripePaymentSheet
//

import CoreGraphics
import Foundation
@_spi(STP) import StripeCore
import UIKit

/// For internal SDK use only.
@objc(STP_Internal_DownloadManager)
// TODO: https://jira.corp.stripe.com/browse/MOBILESDK-2604 Refactor this!
@_spi(STP) public class DownloadManager: NSObject {
public typealias UpdateImageHandler = (UIImage) -> Void

/// Downloads images and caches both their responses and decoded representations.
@_spi(STP) public final class DownloadManager {
// Keep this internal: ErrorAnalytic records its reflected type name.
enum Error: Swift.Error {
case failedToMakeImageFromData
}

public static let sharedManager = DownloadManager()
public static let shared = DownloadManager()

private static let decodedImageCacheCostLimit = 5_000_000
private static let responseCacheMemoryCapacity = 5_000_000
private static let responseCacheDiskCapacity = 30_000_000

private let session: URLSession
private let analyticsClient: STPAnalyticsClient
private let imageCacheLock = NSLock()
private var imageCache: [URL: UIImage] = [:]
private let imageCache = NSCache<NSURL, UIImage>()

public init(
urlSessionConfiguration: URLSessionConfiguration = .default,
analyticsClient: STPAnalyticsClient = .sharedClient,
isTesting: Bool = false
) {
let configuration = urlSessionConfiguration
if !isTesting, let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
.first
{
let diskCacheURL = cachesURL.appendingPathComponent("STPCache")
// 5MB memory cache, 30MB Disk cache
let cache = URLCache(
memoryCapacity: 5_000_000,
diskCapacity: 30_000_000,
directory: diskCacheURL
)
configuration.urlCache = cache
configuration.requestCachePolicy = .useProtocolCachePolicy
}
private convenience init() {
self.init(urlSessionConfiguration: Self.makeDefaultConfiguration())
}

session = URLSession(configuration: configuration)
init(
urlSessionConfiguration: URLSessionConfiguration,
analyticsClient: STPAnalyticsClient = .sharedClient
) {
session = URLSession(configuration: urlSessionConfiguration)
self.analyticsClient = analyticsClient
super.init()
imageCache.totalCostLimit = Self.decodedImageCacheCostLimit
}
}

// MARK: - Download management
extension DownloadManager {

/// Downloads an image from a provided URL, using either a synchronous method or an asynchronous method.
/// If no `updateHandler` is provided, this function will block the current thread until the image is downloaded. If an `updateHandler` is provided, the function does not wait for the download to finish and returns the image if it was cached or a placeholder image instead. When the image finishes downloading, the `updateHandler` will be called with the downloaded image.
/// - Parameters:
/// - url: The URL from which to download the image.
/// - placeholder: An optional parameter indicating a placeholder image to display while the download is in progress. If not provided, a default placeholder image will be used instead.
/// - updateHandler: An optional closure that's called when the image finishes downloading. The downloaded image is passed as a parameter to this closure.
///
/// - Returns: A `UIImage` instance. If `updateHandler` is `nil`, this would be the downloaded image, otherwise, this would be the placeholder image.
public func downloadImage(url: URL, placeholder: UIImage?, updateHandler: UpdateImageHandler?) -> UIImage {
let placeholder = placeholder ?? imagePlaceHolder()
imageCacheLock.lock()
var cachedImage = imageCache[url]
imageCacheLock.unlock()

// If there is no cached image, attempt to promote from diskCache
if cachedImage == nil,
let diskImage = promoteFromDiskCache(url: url) {
cachedImage = diskImage
}

if let updateHandler {
Task {
if let image = try? await downloadImageSkippingCacheRead(url: url) {
updateHandler(image)
}
}
}
// Immediately return the cached image or a placeholder. When the download operation completes `updateHandler` will be called with the downloaded image.
return cachedImage ?? placeholder
/// Returns an image from the in-memory cache without performing any I/O.
public func cachedImage(for url: URL) -> UIImage? {
imageCache.object(forKey: url as NSURL)
}

/// Downloads an image from a provided URL asynchronously.
/// - Parameter url: The URL from which to download the image.
/// - Returns: The downloaded image.
/// Throws if an error occurs while downloading the image.
public func downloadImage(url: URL) async throws -> UIImage {
if let cachedImage = imageCacheLock.withLock( { imageCache[url] }) {
return cachedImage
/// Synchronously promotes an image from the URL response cache into the decoded image cache.
func promoteCachedImage(for url: URL) -> UIImage? {
if let image = cachedImage(for: url) {
return image
}

return try await downloadImageSkippingCacheRead(url: url)
}

// Common download functions

private func promoteFromDiskCache(url: URL) -> UIImage? {
let request = URLRequest(url: url)
guard let cachedResponse = session.configuration.urlCache?.cachedResponse(for: request),
let image = try? UIImage.from(imageData: cachedResponse.data) else {
guard let data = session.configuration.urlCache?.cachedResponse(for: request)?.data,
let image = try? Self.decodeImage(from: data) else {
return nil
}
imageCacheLock.withLock {
imageCache[url] = image
}
cache(image, for: url as NSURL)
return image
}

private func downloadImageSkippingCacheRead(url: URL) async throws -> UIImage {
/// Returns the image at `url`, using cached data whenever possible.
public func image(for url: URL) async throws -> UIImage {
if let image = cachedImage(for: url) {
return image
}

var errorParams: [String: Any] = ["url": url.absoluteString]
do {
let (data, response) = try await session.data(from: url)
// log extra info about response for analytics in case of error
if let httpResponse = response as? HTTPURLResponse {
errorParams["http_status"] = httpResponse.statusCode
errorParams["content_type"] = httpResponse.allHeaderFields["Content-Type"]
errorParams["content_length"] = httpResponse.allHeaderFields["Content-Length"]
}
let image = try UIImage.from(imageData: data) // Throws a Error.failedToMakeImageFromData
Task {
// Cache the image in memory
self.imageCacheLock.withLock {
self.imageCache[url] = image
}
if let response = response as? HTTPURLResponse {
errorParams["http_status"] = response.statusCode
errorParams["content_type"] = response.value(forHTTPHeaderField: "Content-Type")
errorParams["content_length"] = response.value(forHTTPHeaderField: "Content-Length")
}

let image = try Self.decodeImage(from: data)
cache(image, for: url as NSURL)
return image
} catch {
let errorAnalytic = ErrorAnalytic(event: .stripePaymentSheetDownloadManagerError,
error: error,
additionalNonPIIParams: errorParams)
analyticsClient.log(analytic: errorAnalytic)
if (error as? URLError)?.code != .cancelled {
analyticsClient.log(
analytic: ErrorAnalytic(
event: .stripePaymentSheetDownloadManagerError,
error: error,
additionalNonPIIParams: errorParams
)
)
}
throw error
}
}

func resetCache() {
func clearCache() {
session.configuration.urlCache?.removeAllCachedResponses()
imageCacheLock.lock()
imageCache = [:]
imageCacheLock.unlock()
}
}

// MARK: Image Placeholder
extension DownloadManager {
public func imagePlaceHolder() -> UIImage {
return imageWithSize(size: CGSize(width: 1.0, height: 1.0))
imageCache.removeAllObjects()
}

private func imageWithSize(size: CGSize) -> UIImage {
let rect = CGRect(x: 0, y: 0, width: size.width, height: size.height)
UIGraphicsBeginImageContextWithOptions(size, false, 0.0)
UIColor.clear.set()
UIRectFill(rect)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return image!
private func cache(_ image: UIImage, for key: NSURL) {
imageCache.setObject(image, forKey: key, cost: image.decodedByteCount)
}
}

// MARK: UIImage helpers
private extension UIImage {
static func from(imageData: Data) throws -> UIImage {
private static func decodeImage(from data: Data) throws -> UIImage {
#if os(visionOS)
let scale = 1.0
#else
let scale = UIScreen.main.scale
#endif
guard let image = UIImage(data: imageData, scale: scale) else {
throw DownloadManager.Error.failedToMakeImageFromData
guard let image = UIImage(data: data, scale: scale) else {
throw Error.failedToMakeImageFromData
}

return image
}

private static func makeDefaultConfiguration() -> URLSessionConfiguration {
let configuration = URLSessionConfiguration.default
let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
.first?
.appendingPathComponent("STPCache")
configuration.urlCache = URLCache(
memoryCapacity: responseCacheMemoryCapacity,
diskCapacity: responseCacheDiskCapacity,
directory: directory
)
configuration.requestCachePolicy = .useProtocolCachePolicy
return configuration
}
}

private extension UIImage {
var decodedByteCount: Int {
guard let cgImage else { return 0 }
return cgImage.bytesPerRow * cgImage.height
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ extension UIImageView {
tag = url.hashValue
Task { [weak self] in
do {
let image = try await DownloadManager.sharedManager.downloadImage(url: url)
let image = try await DownloadManager.shared.image(for: url)
let processedImage = processOnDownloadedImage?(image) ?? image
await MainActor.run {
if self?.tag == url.hashValue {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ final class LinkFullConsentHeaderView: UIView {
private extension UIImageView {
func setImage(from url: URL) {
Task {
guard let image = try? await DownloadManager.sharedManager.downloadImage(url: url) else {
guard let image = try? await DownloadManager.shared.image(for: url) else {
return
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ private extension UIImageView {
func setImageAsTemplate(from url: URL, placeholder: Image) {
image = placeholder.makeImage(template: true)
Task {
guard let image = try? await DownloadManager.sharedManager.downloadImage(url: url) else {
guard let image = try? await DownloadManager.shared.image(for: url) else {
return
}
await MainActor.run {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ extension LinkPaymentMethodPicker {

var paymentMethod: ConsumerPaymentDetails? {
didSet {
iconTask?.cancel()
switch paymentMethod?.details {
case .card(let card):
cardBrandView.setCardBrand(STPCard.brand(from: card.brand))
Expand Down Expand Up @@ -87,6 +88,7 @@ extension LinkPaymentMethodPicker {
}()

private lazy var cardBrandView: CardBrandView = CardBrandView(centerHorizontally: true)
private var iconTask: Task<Void, Never>?

private let primaryLabel: UILabel = {
let label = UILabel()
Expand Down Expand Up @@ -184,6 +186,10 @@ extension LinkPaymentMethodPicker {
fatalError("init(coder:) has not been implemented")
}

deinit {
iconTask?.cancel()
}

private func makeBankIcon(for bankName: String?) -> UIImage {
if let institutionIcon = PaymentSheetImageLibrary.bankInstitutionIcon(for: bankName) {
return institutionIcon
Expand Down Expand Up @@ -219,15 +225,15 @@ extension LinkPaymentMethodPicker {

private func loadRemoteIcon(from url: URL) {
let placeholder = createGenericPaymentMethodIcon()
genericIconView.image = DownloadManager.sharedManager.downloadImage(
url: url,
placeholder: placeholder,
updateHandler: { [weak self] image in
DispatchQueue.main.async {
self?.genericIconView.image = image
}
genericIconView.image = DownloadManager.shared.cachedImage(for: url) ?? placeholder
iconTask = Task { @MainActor [weak self] in
guard let image = try? await DownloadManager.shared.image(for: url),
!Task.isCancelled,
self?.paymentMethod?.display?.icon?.main == url else {
return
}
)
self?.genericIconView.image = image
}
}

private func refreshBankIconIfNeeded() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ final class AdaptivePricingFlagImageManager {
private func downloadFlagImage(countryCode: String) async -> FlagResult {
let url = makeFlagImageURL(countryCode: countryCode)
do {
let image = try await downloadManager.downloadImage(url: url)
let image = try await downloadManager.image(for: url)
return .success(image)
} catch {
return .failure(url)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,9 @@ extension CustomerSheet {
guard let matchingPaymentMethod = paymentMethods.first(where: { $0.stripeId == paymentMethodId }) else {
return nil
}
matchingPaymentMethod.preloadCardArtImage()
Task {
await matchingPaymentMethod.preloadCardArtImage()?.value
}
return CustomerSheet.PaymentOptionSelection.paymentMethod(matchingPaymentMethod)
default:
return nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ final class IntentConfirmParams {
if let bankName = (financialConnectionsLinkedBank?.bankName ?? instantDebitsLinkedBank?.bankName) {
return PaymentSheetImageLibrary.bankIcon(for: PaymentSheetImageLibrary.bankIconCode(for: bankName), iconStyle: iconStyle)
} else {
return paymentMethodParams.makeIcon(forDarkBackground: forDarkBackground, currency: currency, iconStyle: iconStyle, updateHandler: nil)
return paymentMethodParams.makeIcon(forDarkBackground: forDarkBackground, currency: currency, iconStyle: iconStyle)
}
}

Expand Down
Loading
Loading