amplify-swift/AmplifyPlugins/Analytics/Sources/AWSPinpointAnalyticsPlugin/Dependency/Pinpoint/PinpointContext.swift

307 lines
13 KiB
Swift

//
// Copyright Amazon.com Inc. or its affiliates.
// All Rights Reserved.
//
// SPDX-License-Identifier: Apache-2.0
//
import Amplify
import AWSClientRuntime
import AWSPinpoint
import Foundation
@_spi(KeychainStore) import AWSPluginsCore
// MARK: - UserDefaultsBehaviour
protocol UserDefaultsBehaviour {
func save(_ value: UserDefaultsBehaviourValue?, forKey key: String)
func removeObject(forKey key: String)
func string(forKey key: String) -> String?
func data(forKey key: String) -> Data?
func object(forKey: String) -> Any?
}
protocol UserDefaultsBehaviourValue {}
extension String: UserDefaultsBehaviourValue {}
extension Data: UserDefaultsBehaviourValue {}
extension Dictionary: UserDefaultsBehaviourValue {}
extension UserDefaults: UserDefaultsBehaviour {
func save(_ value: UserDefaultsBehaviourValue?, forKey key: String) {
set(value, forKey: key)
}
}
// MARK: - FileManagerBehaviour
protocol FileManagerBehaviour {
func removeItem(atPath path: String) throws
func urls(for directory: FileManager.SearchPathDirectory, in domainMask: FileManager.SearchPathDomainMask) -> [URL]
func fileExists(atPath path: String) -> Bool
func fileSize(for url: URL) -> Byte
func createDirectory(atPath path: String, withIntermediateDirectories createIntermediates: Bool) throws
}
extension FileManager: FileManagerBehaviour, DefaultLogger {
func createDirectory(atPath path: String, withIntermediateDirectories createIntermediates: Bool) throws {
try createDirectory(atPath: path,
withIntermediateDirectories: createIntermediates,
attributes: nil)
}
func fileSize(for url: URL) -> Byte {
do {
let attributes = try self.attributesOfItem(atPath: url.path)
return attributes[.size] as? Byte ?? 0
} catch {
log.error("Error getting file size with error \(error)")
}
return 0
}
}
typealias Byte = Int
// MARK: - PinpointContext
/// The configuration object containing the necessary and optional configurations required to use AWSPinpoint
struct PinpointContextConfiguration {
/// The Pinpoint Application ID
let appId: String
/// The Pinpoint region
let region: String
/// Used to retrieve the proper AWSCredentials when creating the PinpointCLient
let credentialsProvider: CredentialsProvider
/// The max storage size to use for event storage in bytes. Defaults to 5 MB.
let maxStorageSize: Byte
/// Indicates if the App is in Debug or Release build. Defaults to `false`
/// Setting this flag to true will set the Endpoint Profile to have a channel type of "APNS_SANDBOX".
let isDebug: Bool
/// Indicates whether or not the Targeting Client should set application level OptOut.
/// Use it to configure whether or not the client should receive push notifications at an application level.
/// If System-level notifications for this application are disabled, this will be ignored.
let isApplicationLevelOptOut: Bool
/// Indicates whether to track application sessions. Defaults to `true`
let shouldTrackAppSessions: Bool
/// The amount of time to wait before ending a session after going to the background. Only valid when `shouldTrackAppSessions` is `true` and only for devices not running macOS.
let sessionBackgroundTimeout: TimeInterval
init(appId: String,
region: String,
credentialsProvider: CredentialsProvider,
maxStorageSize: Byte = (1024 * 1024 * 5),
isDebug: Bool = false,
isApplicationLevelOptOut: Bool = false,
shouldTrackAppSessions: Bool = true,
sessionBackgroundTimeout: TimeInterval) {
self.appId = appId
self.region = region
self.credentialsProvider = credentialsProvider
self.sessionBackgroundTimeout = sessionBackgroundTimeout
self.maxStorageSize = maxStorageSize
self.isDebug = isDebug
self.isApplicationLevelOptOut = isApplicationLevelOptOut
self.shouldTrackAppSessions = shouldTrackAppSessions
}
}
/// An internal helper struct used to group all the storage dependencies that can be provided.
private struct PinpointContextStorage {
let userDefaults: UserDefaultsBehaviour
let keychainStore: KeychainStoreBehavior
let fileManager: FileManagerBehaviour
let archiver: AmplifyArchiverBehaviour
}
class PinpointContext {
let endpointClient: EndpointClientBehaviour
let sessionClient: SessionClientBehaviour
let analyticsClient: AnalyticsClientBehaviour
private let uniqueId: String
private let configuration: PinpointContextConfiguration
private let storage: PinpointContextStorage
init(with configuration: PinpointContextConfiguration,
endpointInformation: EndpointInformation = .current,
userDefaults: UserDefaultsBehaviour = UserDefaults.standard,
keychainStore: KeychainStoreBehavior = KeychainStore(service: PinpointContext.Constants.Keychain.service),
fileManager: FileManagerBehaviour = FileManager.default,
archiver: AmplifyArchiverBehaviour = AmplifyArchiver()) throws {
storage = PinpointContextStorage(userDefaults: userDefaults,
keychainStore: keychainStore,
fileManager: fileManager,
archiver: archiver)
uniqueId = Self.retrieveUniqueId(applicationId: configuration.appId, storage: storage)
let pinpointClient = try PinpointClient(region: configuration.region,
credentialsProvider: configuration.credentialsProvider)
endpointClient = EndpointClient(configuration: .init(appId: configuration.appId,
uniqueDeviceId: uniqueId,
isDebug: configuration.isDebug,
isOptOut: configuration.isApplicationLevelOptOut),
pinpointClient: pinpointClient,
endpointInformation: endpointInformation,
userDefaults: userDefaults,
keychain: keychainStore)
sessionClient = SessionClient(archiver: archiver,
configuration: .init(appId: configuration.appId,
uniqueDeviceId: uniqueId,
sessionBackgroundTimeout: configuration.sessionBackgroundTimeout),
endpointClient: endpointClient,
userDefaults: userDefaults)
let sessionProvider: () -> PinpointSession = { [weak sessionClient] in
guard let sessionClient = sessionClient else {
fatalError("SessionClient was deallocated")
}
return sessionClient.currentSession
}
analyticsClient = try AnalyticsClient(applicationId: configuration.appId,
pinpointClient: pinpointClient,
endpointClient: endpointClient,
sessionProvider: sessionProvider)
sessionClient.analyticsClient = analyticsClient
if configuration.shouldTrackAppSessions {
sessionClient.startPinpointSession()
}
self.configuration = configuration
}
private static func legacyPreferencesFilePath(applicationId: String,
storage: PinpointContextStorage) -> String? {
let applicationSupportDirectoryUrls = storage.fileManager.urls(for: .applicationSupportDirectory,
in: .userDomainMask)
let preferencesFileUrl = applicationSupportDirectoryUrls.first?
.appendingPathComponent(Constants.Preferences.mobileAnalyticsRoot)
.appendingPathComponent(applicationId)
.appendingPathComponent(Constants.Preferences.fileName)
return preferencesFileUrl?.path
}
private static func removeLegacyPreferencesFile(applicationId: String,
storage: PinpointContextStorage) {
guard let preferencesPath = legacyPreferencesFilePath(applicationId: applicationId,
storage: storage) else {
return
}
do {
try storage.fileManager.removeItem(atPath: preferencesPath)
} catch {
log.verbose("Cannot remove legacy preferences file")
}
}
private static func legacyUniqueId(applicationId: String,
storage: PinpointContextStorage) -> String? {
guard let preferencesPath = legacyPreferencesFilePath(applicationId: applicationId,
storage: storage),
storage.fileManager.fileExists(atPath: preferencesPath),
let preferencesJson = try? JSONSerialization.jsonObject(with: Data(contentsOf: URL(fileURLWithPath: preferencesPath)),
options: .mutableContainers) as? [String: String] else {
return nil
}
return preferencesJson[Constants.Preferences.uniqueIdKey]
}
/**
Attempts to retrieve a previously generated Device Unique ID.
This value can be present in 3 places:
1. In a preferences file stored in disk
2. In UserDefauls
3. In the Keychain
1 and 2 are legacy storage options that are supportted for backwards compability, but once retrieved those values will be migrated to the Keychain.
If no existing Device Unique ID is found, a new one will be generated and stored in the Keychain.
- Returns: A string representing the Device Unique ID
*/
private static func retrieveUniqueId(applicationId: String,
storage: PinpointContextStorage) -> String {
// 1. Look for the UniqueId in the Keychain
if let deviceUniqueId = try? storage.keychainStore._getString(Constants.Keychain.uniqueIdKey) {
return deviceUniqueId
}
// 2. Look for UniqueId in the legacy preferences file
if let legacyUniqueId = legacyUniqueId(applicationId: applicationId, storage: storage) {
do {
// Attempt to migrate to Keychain
try storage.keychainStore._set(legacyUniqueId, key: Constants.Keychain.uniqueIdKey)
log.verbose("Migrated Legacy Pinpoint UniqueId to Keychain: \(legacyUniqueId)")
// Delete the old file
removeLegacyPreferencesFile(applicationId: applicationId, storage: storage)
} catch {
log.error("Failed to migrate UniqueId to Keychain from preferences file")
log.verbose("Fallback: Migrate UniqueId to UserDefaults: \(legacyUniqueId)")
// Attempt to migrate to UserDefaults
storage.userDefaults.save(legacyUniqueId, forKey: Constants.Keychain.uniqueIdKey)
// Delete the old file
removeLegacyPreferencesFile(applicationId: applicationId, storage: storage)
}
return legacyUniqueId
}
// 3. Look for UniqueID in UserDefaults
if let userDefaultsUniqueId = storage.userDefaults.string(forKey: Constants.Keychain.uniqueIdKey) {
// Attempt to migrate to Keychain
do {
try storage.keychainStore._set(userDefaultsUniqueId, key: Constants.Keychain.uniqueIdKey)
log.verbose("Migrated Pinpoint UniqueId from UserDefaults to Keychain: \(userDefaultsUniqueId)")
// Delete the UserDefault entry
storage.userDefaults.removeObject(forKey: Constants.Keychain.uniqueIdKey)
} catch {
log.error("Failed to migrate UniqueId from UserDefaults to Keychain")
}
return userDefaultsUniqueId
}
// 4. Create a new ID
let newUniqueId = UUID().uuidString
do {
try storage.keychainStore._set(newUniqueId, key: Constants.Keychain.uniqueIdKey)
log.verbose("Created new Pinpoint UniqueId and saved it to Keychain: \(newUniqueId)")
} catch {
log.error("Failed to save UniqueId in Keychain")
log.verbose("Fallback: Created new Pinpoint UniqueId and saved it to UserDefaults: \(newUniqueId)")
storage.userDefaults.save(newUniqueId, forKey: Constants.Keychain.uniqueIdKey)
}
return newUniqueId
}
}
// MARK: - DefaultLogger
extension PinpointContext: DefaultLogger {}
extension PinpointContext {
struct Constants {
struct Preferences {
static let mobileAnalyticsRoot = "com.amazonaws.MobileAnalytics"
static let fileName = "preferences"
static let uniqueIdKey = "UniqueId"
}
struct Keychain {
static let service = "com.amazonaws.AWSPinpointContext"
static let uniqueIdKey = "com.amazonaws.AWSPinpointContextKeychainUniqueIdKey"
}
}
}