diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 000000000..ac2caabdf --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,126 @@ +# TODO: +# 1. PARRA_TEST_OUTPUT_DIRECTORY not working + +orbs: + # ruby: circleci/ruby@2.1.0 + macos: circleci/macos@2.4.1 + git-shallow-clone: guitarrapc/git-shallow-clone@2.8.0 + bun: ksylvest/bun@1.0.1 + +commands: + common: + steps: + - git-shallow-clone/checkout: + depth: 1 + fetch_depth: 1 + # Enable this if we get errors related to GitHub RSA failure during clone. + # keyscan_github: true + no_tags: true + - attach_workspace: + at: . + - bun/install + - run: + name: Install NPM Dependencies + command: bun install --frozen-lockfile + load-env-vars: + steps: + - run: + name: Loading Environment Variables + command: | + cat bash.env >> $BASH_ENV + printenv + + run-command-and-persist-variables: + parameters: + title: + type: string + command: + type: string + variables: + type: string # CSV of variables to persist + background: + type: boolean + default: false + steps: + - run: + name: << parameters.title >> + command: | + # Important to source from the command that we're executing to store env vars that are + # exported in the command. + . << parameters.command >> + + # Iterate over variables and persist them + IFS=',' read -ra ADDR \<<< "<< parameters.variables >>" + for i in "${ADDR[@]}"; do + value=$(eval echo \$$i) + echo "Persisting variable: $i with value: $value" + echo "export $i=$value" >> $BASH_ENV + done + + printenv + cp $BASH_ENV bash.env + background: << parameters.background >> + + prepare_ios_prerequisites: + steps: + - run: + name: Install Brew Dependencies + command: ./cli.sh ci --install-brew-dependencies + # - run-command-and-persist-variables: + # title: Preboot Simulator + # command: ./cli/bin/preboot-simulator.sh + # variables: PARRA_TEST_DEVICE_UDID + # # The time saved by running this in the background is negated by the increased time the + # # build-for-testing script will take if a simulator is mid booted when it starts. + # background: false + - run: + name: Extract ASC Credential + command: | + dirname $PARRA_ASC_API_KEY_PATH | xargs mkdir -p + echo $PARRA_ASC_API_KEY | base64 --decode > $PARRA_ASC_API_KEY_PATH + # - run: + # name: Disable Simulator Hardware Keyboard + # command: ./cli.sh ci --disable-simulator-hardware-keyboard + # - ruby/install-deps: # Don't need to set bundler-version, ORB looks for Gemfile.lock by default. + # path: /tmp/workspace/vendor + # include-branch-in-cache-key: false + +executors: + macos-m1-test-runner: + macos: + xcode: 15.2.0 + resource_class: macos.m1.medium.gen1 + environment: + PARRA_TEST_DERIVED_DATA_DIRECTORY: build/unit-tests/derivedData + PARRA_TEST_OUTPUT_DIRECTORY: artifacts/unit-tests + PARRA_TEST_PROJECT_NAME: ./Parra.xcodeproj + PARRA_TEST_SCHEME_NAME: Parra + PARRA_TEST_CONFIGURATION: Debug + PARRA_TEST_DEVICE_NAME: iPhone 15 + PARRA_TEST_DEVICE_OS_VERSION: 17.2 + PARRA_TEST_DESTINATION: platform=iOS Simulator,name=iPhone 15,OS=17.2 + PARRA_ASC_API_KEY_PATH: ./artifacts/asc-key.p8 + working_directory: /tmp/workspace + +version: 2.1 +jobs: + build-and-test: + executor: macos-m1-test-runner + steps: + - common + - prepare_ios_prerequisites + - run: + name: Build for Testing + command: ./cli.sh tests --build --log-level debug + # - macos/wait-until-simulator-booted: + # device-udid-var: PARRA_TEST_DEVICE_UDID + - run: + name: Run Unit Tests + command: ./cli.sh tests --run --log-level debug + - store_test_results: + path: build/unit-tests/derivedData/Logs/Test + +workflows: + run-unit-tests: + jobs: + - build-and-test diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 000000000..8a0d41c51 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,41 @@ +const ignorePatternsForDirectories = (dirs) => { + return dirs.map((dir) => `**/${dir}/*`); +}; + +module.exports = { + root: true, + env: { + browser: false, + es2021: true, + }, + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], + overrides: [ + { + env: { + node: true, + }, + files: ['.eslintrc.{js,cjs}'], + parserOptions: { + sourceType: 'script', + }, + }, + ], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + plugins: ['@typescript-eslint', 'unused-imports'], + rules: { + 'no-unused-vars': 'off', // or "@typescript-eslint/no-unused-vars": "off", + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + }, + ignorePatterns: [ + ...ignorePatternsForDirectories([ + 'node_modules', + 'build', + 'dist', + 'vendor', + ]), + ], +}; diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..60e229106 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ + +# Set the following locally +# git config diff.lockb.textconv bun +# git config diff.lockb.binary true +*.lockb binary diff=lockb diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml deleted file mode 100644 index 21adf4a11..000000000 --- a/.github/workflows/ios.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Build and Run XCTests - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - build: - runs-on: macos-13 - strategy: - matrix: - include: - - xcode: "14.3.1" - ios: "16.4" - device: "iPhone 14" - name: Parra Tests on iOS (${{ matrix.ios }}) - - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Select Xcode - run: sudo xcode-select -switch /Applications/Xcode_${{ matrix.xcode }}.app && /usr/bin/xcodebuild -version - - name: Run Parra Unit Tests - run: xcodebuild test -scheme Parra -project Parra.xcodeproj -sdk iphonesimulator -testPlan Parra -destination 'platform=iOS Simulator,name=${{ matrix.device }},OS=${{ matrix.ios }}' | xcpretty && exit ${PIPESTATUS[0]} diff --git a/.gitignore b/.gitignore index ec4bc4712..86d67fbcb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ - # Created by https://www.toptal.com/developers/gitignore/api/macos,cocoapods,xcode,swift,objective-c,reactnative # Edit at https://www.toptal.com/developers/gitignore?templates=macos,cocoapods,xcode,swift,objective-c,reactnative @@ -19,7 +18,6 @@ Pods/ # Icon must end with two \r Icon - # Thumbnails ._* @@ -125,7 +123,7 @@ __generated__ *.class # Generated files -bin/ +/bin/ gen/ out/ # Uncomment the following line in case you need and you don't have the release build type files in your app @@ -276,8 +274,8 @@ bower_components build/Release # Dependency directories -node_modules/ -jspm_packages/ +**/node_modules/ +**/jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ @@ -321,7 +319,7 @@ out # Nuxt.js build / generate output .nuxt -dist +**/dist # Gatsby files .cache/ @@ -358,9 +356,6 @@ dist # Xcode # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore - - - ## Gcc Patch /*.gcno @@ -379,11 +374,6 @@ dist # Xcode # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore - - - - - ## Playgrounds timeline.xctimeline playground.xcworkspace @@ -412,7 +402,6 @@ playground.xcworkspace # Add this line if you want to avoid checking in source code from Carthage dependencies. # Carthage/Checkouts - # Accio dependency management Dependencies/ .accio/ @@ -423,20 +412,14 @@ Dependencies/ # For more information about the recommended setup visit: # https://docs.fastlane.tools/best-practices/source-control/#source-control - # Code Injection # After new code Injection tools there's a generated folder /iOSInjectionProject # https://github.com/johnno1962/injectionforxcode - ### Xcode ### # Xcode # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore - - - - ### Xcode Patch ### *.xcodeproj/* !*.xcodeproj/project.pbxproj @@ -446,3 +429,5 @@ Dependencies/ # End of https://www.toptal.com/developers/gitignore/api/macos,cocoapods,xcode,swift,objective-c,reactnative /.vscode/settings.json +/buildlog +/artifacts diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..679241023 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +21.4.0 \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..cfa48ca07 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,18 @@ +# Ignore artifacts: +build +coverage + +# Ignore all HTML files: +**/*.html + +**/.git +**/.svn +**/.hg +**/node_modules + +**/.git +**/.svn +**/.hg + +**/dist +**/build \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..2ff3762e7 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": true, + "singleQuote": true, + "bracketSpacing": true, + "arrowParens": "always", + "printWidth": 80 +} diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 000000000..0aec50e6e --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.1.4 diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..5b029ace7 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "bierner.github-markdown-preview", + "bierner.markdown-preview-github-styles", + "circleci.circleci", + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..33d31c1de --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,34 @@ +{ + "configurations": [ + { + "name": "Build Tests", + "type": "node", + "request": "launch", + + // Debug current file in VSCode + "program": "${file}", + + /* + Path to tsx binary + Assuming locally installed + */ + "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx", + + /* + Open terminal when debugging starts (Optional) + Useful to see console.logs + */ + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + + // Files to exclude from debugger (e.g. call stack) + "skipFiles": [ + // Node.js internal core modules + "/**", + + // Ignore all dependencies (optional) + "${workspaceFolder}/node_modules/**" + ] + } + ] +} diff --git a/Demo/AppDelegate.swift b/Demo/AppDelegate.swift index 21336beb5..461940836 100644 --- a/Demo/AppDelegate.swift +++ b/Demo/AppDelegate.swift @@ -8,29 +8,40 @@ import UIKit import Parra +fileprivate let logger = Logger(category: "UIApplicationDelegate methods") + @main class AppDelegate: UIResponder, UIApplicationDelegate { - func application(_ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + logger.info("Application finished launching") guard NSClassFromString("XCTestCase") == nil else { return true } - let myAppAccessToken = "9B5CDA6B-7538-4A2A-9611-7308D56DFFA1" // Find this at https://dashboard.parra.io/settings let myParraTenantId = "4caab3fe-d0e7-4bc3-9d0a-4b36f32bd1b7" // Find this at https://dashboard.parra.io/applications - let myParraApplicationId = "cb22fd90-2abc-4044-b985-fcb86f61daa9" + let myParraApplicationId = "e9869122-fc90-4266-9da7-e5146d70deab" + + logger.debug("Initializing Parra") Parra.initialize( - config: .default, + options: [ + .logger(options: .default), + .pushNotifications + ], authProvider: .default( tenantId: myParraTenantId, applicationId: myParraApplicationId ) { + logger.info("Parra authentication provider invoked") + var request = URLRequest( // Replace this with your Parra access token generation endpoint url: URL(string: "http://localhost:8080/v1/parra/auth/token")! @@ -41,15 +52,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { request.setValue("Bearer \(myAppAccessToken)", forHTTPHeaderField: "Authorization") let (data, _) = try await URLSession.shared.data(for: request) - let response = try JSONDecoder().decode([String: String].self, from: data) + let response = try JSONDecoder().decode([String : String].self, from: data) return response["access_token"]! } ) - // Call this after Parra.initialize() - application.registerForRemoteNotifications() - return true } @@ -74,6 +82,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { + logger.info("Successfully registered for push notifications") + Parra.registerDevicePushToken(deviceToken) } @@ -81,6 +91,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error ) { + logger.warn("Failed to register for push notifications") + Parra.didFailToRegisterForRemoteNotifications(with: error) } } diff --git a/Demo/Base.lproj/Main.storyboard b/Demo/Base.lproj/Main.storyboard index 5efbca794..63213e063 100644 --- a/Demo/Base.lproj/Main.storyboard +++ b/Demo/Base.lproj/Main.storyboard @@ -1,27 +1,27 @@ - + - - + - + - + + - + @@ -41,13 +41,13 @@ - + - + @@ -67,13 +67,13 @@ - + - + @@ -93,13 +93,13 @@ - + - + @@ -119,13 +119,13 @@ - + - + @@ -145,7 +145,7 @@ - + @@ -158,7 +158,7 @@ - + @@ -235,7 +235,7 @@ diff --git a/Demo/ParraCardsInCollectionView.swift b/Demo/ParraCardsInCollectionView.swift index d23b18bf0..7e7b2ad0f 100644 --- a/Demo/ParraCardsInCollectionView.swift +++ b/Demo/ParraCardsInCollectionView.swift @@ -41,7 +41,7 @@ class ParraCardsInCollectionView: UICollectionViewController, UICollectionViewDe flowLayout.sectionInset = .init(top: 8, left: 8, bottom: 8, right: 8) } - ParraFeedback.fetchFeedbackCards { [self] cards, error in + ParraFeedback.shared.fetchFeedbackCards { [self] cards, error in if let error = error { navigationItem.prompt = "Error fetching Parra cards" print("Error fetching Parra cards: \(error)") @@ -77,7 +77,7 @@ class ParraCardsInCollectionView: UICollectionViewController, UICollectionViewDe contentConfig.prefersSideBySideTextAndSecondaryText = true cell.contentConfiguration = contentConfig - cell.backgroundColor = .init(white: 0.95, alpha: 1.0) + cell.backgroundColor = .systemBackground cell.layer.cornerRadius = 8 return cell diff --git a/Demo/ParraCardsInModal.swift b/Demo/ParraCardsInModal.swift index 7c6ac945e..a88b37d0b 100644 --- a/Demo/ParraCardsInModal.swift +++ b/Demo/ParraCardsInModal.swift @@ -9,6 +9,8 @@ import UIKit import Parra +fileprivate let logger = Logger(category: "ParraCardsInModal", extra: ["top-level": "extra-thing"]) + class ParraCardsInModal: UIViewController { @IBOutlet weak var popupButton: UIButton! @IBOutlet weak var errorLabel: UILabel! @@ -22,24 +24,56 @@ class ParraCardsInModal: UIViewController { popupButton.isEnabled = false drawerButton.isEnabled = false - ParraFeedback.fetchFeedbackCards { [self] response in - switch response { - case .success(let cards): - self.cards = cards + logger.info("finished disabling buttons", [ + "popupButtonEnabled": popupButton.isEnabled, + "drawerButtonEnabled": drawerButton.isEnabled + ]) + + logger.info("fetching feedback cards") + + ParraFeedback.shared.fetchFeedbackCards { [self] response in + // TODO: If a name isn't provided here, could we capture and name a closure + // to use as the name? + logger.withScope( + named: "fetch cards completion", + ["response": String(describing: response)] + ) { logger in + switch response { + case .success(let cards): + logger.debug("success") + self.cards = cards - popupButton.isEnabled = true - drawerButton.isEnabled = true - case .failure(let error): - errorLabel.text = error.localizedDescription + popupButton.isEnabled = true + drawerButton.isEnabled = true + logger.debug("finished enabling buttons", [ + "popupButtonEnabled": popupButton.isEnabled, + "drawerButtonEnabled": drawerButton.isEnabled + ]) + case .failure(let error): + errorLabel.text = error.localizedDescription + logger.error("failed to fetch cards", error) + } } } } @IBAction func presentPopupStyleFeedbackModal(_ sender: UIButton) { - ParraFeedback.presentCardPopup(with: cards, from: self) + logger.withScope { logger in + logger.info("present popup style") + + ParraFeedback.shared.presentCardPopup(with: cards, from: self) { + logger.info("dismissing popup style", ["super-nested-extra": true]) + } + } } @IBAction func presentDrawerStyleFeedbackModal(_ sender: UIButton) { - ParraFeedback.presentCardDrawer(with: cards, from: self) + logger.error( + "Error presenting drawer feedback modal", + ParraError.message("Not really, it's a fake error"), + ["key": "value-idk-something-broken"] + ) + + ParraFeedback.shared.presentCardDrawer(with: cards, from: self) } } diff --git a/Demo/ParraCardsInTableView.swift b/Demo/ParraCardsInTableView.swift index 92cf09f0d..6086bcc73 100644 --- a/Demo/ParraCardsInTableView.swift +++ b/Demo/ParraCardsInTableView.swift @@ -30,7 +30,7 @@ class ParraCardsInTableView: UITableViewController { tableView.register(ParraCardTableViewCell.self, forCellReuseIdentifier: ParraCardTableViewCell.defaultCellId) - ParraFeedback.fetchFeedbackCards(appArea: .id("07eb0d9a-4912-46b8-b77e-3741753960ac")) { [self] cards, error in + ParraFeedback.shared.fetchFeedbackCards(appArea: .id("07eb0d9a-4912-46b8-b77e-3741753960ac")) { [self] cards, error in if let error = error { navigationItem.prompt = "Error fetching Parra cards" print("Error fetching Parra cards: \(error)") diff --git a/Demo/ParraCardsInView.swift b/Demo/ParraCardsInView.swift index 083754607..d24c59395 100644 --- a/Demo/ParraCardsInView.swift +++ b/Demo/ParraCardsInView.swift @@ -28,7 +28,7 @@ class ParraCardsInView: UIViewController { view.addConstraint(activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor)) print("Fetching Parra Cards...") - ParraFeedback.fetchFeedbackCards { [self] cards, error in + ParraFeedback.shared.fetchFeedbackCards { [self] cards, error in activityIndicator.removeFromSuperview() if error != nil || cards.isEmpty { diff --git a/Demo/ParraFeedbackForm.swift b/Demo/ParraFeedbackForm.swift index 9a34cd543..35ec1d2ef 100644 --- a/Demo/ParraFeedbackForm.swift +++ b/Demo/ParraFeedbackForm.swift @@ -20,7 +20,7 @@ class ParraFeedbackForm: UIViewController { presentFormButton.isEnabled = false - ParraFeedback.fetchFeedbackForm(formId: "c15256c2-d21d-4d9f-85ac-1d4655416a95") { [self] response in + ParraFeedback.shared.fetchFeedbackForm(formId: "c15256c2-d21d-4d9f-85ac-1d4655416a95") { [self] response in switch response { case .success(let data): formData = data @@ -36,6 +36,6 @@ class ParraFeedbackForm: UIViewController { return } - ParraFeedback.presentFeedbackForm(with: formData, from: self) + ParraFeedback.shared.presentFeedbackForm(with: formData, from: self) } } diff --git a/Gemfile b/Gemfile index 39461964f..513f67747 100755 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,3 @@ -# frozen_string_literal: true - source "https://rubygems.org" -# gem "rails" -gem 'xcodeproj', '1.21.0' +gem 'bundler', '2.4.22' diff --git a/Gemfile.lock b/Gemfile.lock index 7d80487af..ac59589d4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,27 +1,13 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.5) - rexml - atomos (0.1.3) - claide (1.1.0) - colored2 (3.1.2) - nanaimo (0.3.0) - rexml (3.2.5) - xcodeproj (1.21.0) - CFPropertyList (>= 2.3.3, < 4.0) - atomos (~> 0.1.3) - claide (>= 1.0.2, < 2.0) - colored2 (~> 3.1) - nanaimo (~> 0.3.0) - rexml (~> 3.2.4) PLATFORMS - arm64-darwin-21 - universal-darwin-22 + arm64-darwin-22 + arm64-darwin-23 DEPENDENCIES - xcodeproj (= 1.21.0) + bundler (= 2.4.22) BUNDLED WITH - 2.3.15 + 2.4.22 diff --git a/Parra.podspec b/Parra.podspec index d69b81a84..c609ff0f5 100644 --- a/Parra.podspec +++ b/Parra.podspec @@ -12,7 +12,7 @@ Pod::Spec.new do |spec| spec.summary = 'A suite of customer feedback tools that allow companies to aggregate user feedback and seamlessly integrate with their mobile apps.' spec.source = { :git => 'https://github.com/Parra-Inc/parra-ios-sdk.git', :tag => "#{TAG}" } spec.module_name = 'Parra' - spec.swift_version = '5.6' + spec.swift_version = '5.8' spec.static_framework = true spec.ios.deployment_target = '13.0' diff --git a/Parra.xcodeproj/project.pbxproj b/Parra.xcodeproj/project.pbxproj index 5fe6079b1..08964b3bc 100644 --- a/Parra.xcodeproj/project.pbxproj +++ b/Parra.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 55; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ @@ -15,12 +15,11 @@ 001559AA27E41A8A006450BE /* ParraStorageModuleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001559A927E41A8A006450BE /* ParraStorageModuleTests.swift */; }; 001559AF27E7E76C006450BE /* CredentialStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001559AE27E7E76C006450BE /* CredentialStorageTests.swift */; }; 001559B127E7EAC0006450BE /* ParraDataManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001559B027E7EAC0006450BE /* ParraDataManagerTests.swift */; }; - 001559B327E7EACC006450BE /* ParraSyncManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001559B227E7EACC006450BE /* ParraSyncManagerTests.swift */; }; 001559B527E7EAD9006450BE /* ParraNetworkManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001559B427E7EAD9006450BE /* ParraNetworkManagerTests.swift */; }; - 0018A60E28D77FF2008CC97E /* URLComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0018A60D28D77FF2008CC97E /* URLComponents.swift */; }; + 0018A60E28D77FF2008CC97E /* URLComponents+queryItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0018A60D28D77FF2008CC97E /* URLComponents+queryItems.swift */; }; 0019CBC328F639040015E703 /* ParraQuestionAppArea.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0019CBC228F639040015E703 /* ParraQuestionAppArea.swift */; }; 0019CBC528FAE0E80015E703 /* FailableDecodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0019CBC428FAE0E80015E703 /* FailableDecodable.swift */; }; - 0019CBC928FB37BC0015E703 /* Sequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0019CBC828FB37BC0015E703 /* Sequence.swift */; }; + 0019CBC928FB37BC0015E703 /* Sequence+asyncMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0019CBC828FB37BC0015E703 /* Sequence+asyncMap.swift */; }; 001B7AFA27DE3A7600DEEECD /* Parra.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 001B7AF227DE3A7600DEEECD /* Parra.framework */; }; 001B7B0127DE3A7600DEEECD /* ParraTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001B7B0027DE3A7600DEEECD /* ParraTests.swift */; }; 001B7B0227DE3A7600DEEECD /* Parra.h in Headers */ = {isa = PBXBuildFile; fileRef = 001B7AF427DE3A7600DEEECD /* Parra.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -29,23 +28,21 @@ 001B7B0D27DE3A9B00DEEECD /* Parra+Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00D5155D27D4223D00C4F2CC /* Parra+Constants.swift */; }; 001B7B0E27DE3A9B00DEEECD /* Parra.swift in Sources */ = {isa = PBXBuildFile; fileRef = 003D2A74274C77BD001E067E /* Parra.swift */; }; 001B7B0F27DE3A9B00DEEECD /* Parra+Authentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0036D60127CF0EA20016DE97 /* Parra+Authentication.swift */; }; - 001B7B1027DE3A9B00DEEECD /* ParraLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00D5156227D424C800C4F2CC /* ParraLogger.swift */; }; 001B7B1127DE3A9B00DEEECD /* ParraModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001B7A8D27DD8B5500DEEECD /* ParraModule.swift */; }; 001B7B1227DE3A9B00DEEECD /* ParraError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0036D5E127CACA100016DE97 /* ParraError.swift */; }; - 001B7B1327DE3A9B00DEEECD /* Parra+Endpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001B7A9127DD8DCE00DEEECD /* Parra+Endpoints.swift */; }; + 001B7B1327DE3A9B00DEEECD /* ParraNetworkManager+Endpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001B7A9127DD8DCE00DEEECD /* ParraNetworkManager+Endpoints.swift */; }; 001B7B1427DE3A9B00DEEECD /* Parra+SyncEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00D5156627D4506B00C4F2CC /* Parra+SyncEvents.swift */; }; 001B7B1627DE3AA900DEEECD /* ParraCredential.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00120F002753E9A400CA3247 /* ParraCredential.swift */; }; 001B7B1727DE3AA900DEEECD /* HttpMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001B7A8B27DD883500DEEECD /* HttpMethod.swift */; }; 001B7B1827DE3AA900DEEECD /* GeneratedTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 007FBEF3277A34C700BD9E37 /* GeneratedTypes.swift */; }; - 001B7B1927DE3AB000DEEECD /* Pacifico-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 001286FC277FE2320090CDB5 /* Pacifico-Regular.ttf */; }; 001B7B1A27DE3ABC00DEEECD /* FileSystemStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0036D5F627CDB0040016DE97 /* FileSystemStorage.swift */; }; 001B7B1D27DE3ABC00DEEECD /* CredentialStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0036D5F027CD9BDA0016DE97 /* CredentialStorage.swift */; }; 001B7B1F27DE3ABC00DEEECD /* PersistentStorageMedium.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0036D5F427CD9E460016DE97 /* PersistentStorageMedium.swift */; }; 001B7B2027DE3ABC00DEEECD /* UserDefaultsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0036D5F827CDB0170016DE97 /* UserDefaultsStorage.swift */; }; - 001B7B2227DE3AC200DEEECD /* FileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0036D60B27D053160016DE97 /* FileManager.swift */; }; + 001B7B2227DE3AC200DEEECD /* FileManager+safePaths.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0036D60B27D053160016DE97 /* FileManager+safePaths.swift */; }; 001B7B2327DE3AC200DEEECD /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E8B32D27878C3400FC3FFD /* Models.swift */; }; 001B7B2527DE3AC200DEEECD /* JSONDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0036D5E527CB0FBD0016DE97 /* JSONDecoder.swift */; }; - 001B7B2627DE3AC200DEEECD /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001286FE277FE3180090CDB5 /* UIFont.swift */; }; + 001B7B2627DE3AC200DEEECD /* UIFont+registration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001286FE277FE3180090CDB5 /* UIFont+registration.swift */; }; 001B7B2727DE3AC200DEEECD /* JSONEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0036D5E327CB0EEA0016DE97 /* JSONEncoder.swift */; }; 001B7B2927DE3AC800DEEECD /* ParraSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00D5156427D4376500C4F2CC /* ParraSyncManager.swift */; }; 001B7B2C27DE3AC800DEEECD /* ParraNetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00D5156827D45C8F00C4F2CC /* ParraNetworkManager.swift */; }; @@ -53,7 +50,21 @@ 001B7B8227DE618300DEEECD /* ParraDataManager+Keys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001B7B8127DE618300DEEECD /* ParraDataManager+Keys.swift */; }; 001B7B8827DE712200DEEECD /* ParraStorageModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001B7B8727DE712200DEEECD /* ParraStorageModule.swift */; }; 001B7B8E27DE806100DEEECD /* DataStorageMedium.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001B7B8D27DE806100DEEECD /* DataStorageMedium.swift */; }; - 00320A5B27ED4EF0001EB323 /* Parra+AuthenticationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00320A5A27ED4EF0001EB323 /* Parra+AuthenticationTests.swift */; }; + 00214C6A2AB3E2BC001C5313 /* ParraSanitizedDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00214C692AB3E2BC001C5313 /* ParraSanitizedDictionary.swift */; }; + 00214C6C2AB3E3A1001C5313 /* ParraDataSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00214C6B2AB3E3A0001C5313 /* ParraDataSanitizer.swift */; }; + 00214C6E2AB62510001C5313 /* String+prefixes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00214C6D2AB62510001C5313 /* String+prefixes.swift */; }; + 0029AD2B2A48DCE100E30CCD /* LoggerHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0029AD2A2A48DCE100E30CCD /* LoggerHelpers.swift */; }; + 0029AD2E2A48E11300E30CCD /* LoggerHelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0029AD2D2A48E11300E30CCD /* LoggerHelpersTests.swift */; }; + 0029AD302A490CDB00E30CCD /* Parra+InternalAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0029AD2F2A490CDB00E30CCD /* Parra+InternalAnalytics.swift */; }; + 0029AD332A49256300E30CCD /* ParraEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0029AD322A49256300E30CCD /* ParraEvent.swift */; }; + 0029AD372A4925C000E30CCD /* ParraStandardEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0029AD362A4925C000E30CCD /* ParraStandardEvent.swift */; }; + 0029AD392A4925D000E30CCD /* ParraInternalEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0029AD382A4925D000E30CCD /* ParraInternalEvent.swift */; }; + 0029AD3C2A49291200E30CCD /* StringManipulators.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0029AD3A2A49290A00E30CCD /* StringManipulators.swift */; }; + 003621D72B5B563C009EFE53 /* ParraLogo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 003621D62B5B563C009EFE53 /* ParraLogo.swift */; }; + 003621DB2B5B569F009EFE53 /* ParraLogoType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 003621DA2B5B569F009EFE53 /* ParraLogoType.swift */; }; + 003621DD2B5B592D009EFE53 /* ParraAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 003621DC2B5B592D009EFE53 /* ParraAssets.xcassets */; }; + 003621E92B5C2784009EFE53 /* ParraLogoButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 003621E82B5C2784009EFE53 /* ParraLogoButton.swift */; }; + 003621EB2B5C2A62009EFE53 /* FeedbackFormViewState+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 003621EA2B5C2A62009EFE53 /* FeedbackFormViewState+Fixtures.swift */; }; 003962332A48B4EF00B8655F /* ParraFeedbackDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001287002780C0C70090CDB5 /* ParraFeedbackDataManager.swift */; }; 003962342A48B4EF00B8655F /* ParraCardAnswerHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E8B32B278730D500FC3FFD /* ParraCardAnswerHandler.swift */; }; 003962352A48B4EF00B8655F /* ParraFeedback+Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001B7B8327DE6E2400DEEECD /* ParraFeedback+Constants.swift */; }; @@ -120,26 +131,99 @@ 003962722A48B5BF00B8655F /* ParraFeedbackFormTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 006153F02A36826400ED4CBF /* ParraFeedbackFormTextFieldView.swift */; }; 003C214727DE855A001CAE03 /* ItemStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 003C214627DE855A001CAE03 /* ItemStorage.swift */; }; 003C216C27DED58C001CAE03 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 003C216B27DED58C001CAE03 /* SceneDelegate.swift */; }; + 0040DEAD2AA226A3007E2190 /* UIViewController+defaultLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0040DEAC2AA226A3007E2190 /* UIViewController+defaultLogger.swift */; }; 00497D7929F88CC4004EF5AB /* ParraSessionsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00497D7829F88CC4004EF5AB /* ParraSessionsResponse.swift */; }; 005D31EB29A83EC5001D0E0A /* CompletedCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001B7B7927DE3D1C00DEEECD /* CompletedCard.swift */; }; 005D31ED29A8561D001D0E0A /* QuestionAnswer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 005D31EC29A8561D001D0E0A /* QuestionAnswer.swift */; }; - 005E38A527FA1E0200F32F96 /* NetworkTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 005E38A427FA1E0200F32F96 /* NetworkTestHelpers.swift */; }; 005E38A727FA3A8700F32F96 /* Parra+Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 005E38A627FA3A8700F32F96 /* Parra+Notifications.swift */; }; 006153E42A31539900ED4CBF /* ParraFeedbackForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 006153E32A31539900ED4CBF /* ParraFeedbackForm.swift */; }; + 0066540A2A7EAAFA00CD04E6 /* URLRequest+ParraDictionaryConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 006654092A7EAAFA00CD04E6 /* URLRequest+ParraDictionaryConvertible.swift */; }; + 0066540C2A7EAEF000CD04E6 /* ParraErrorWithExtra.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0066540B2A7EAEF000CD04E6 /* ParraErrorWithExtra.swift */; }; + 0066540E2A7EBAAF00CD04E6 /* HTTPURLResponse+ParraDictionaryConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0066540D2A7EBAAF00CD04E6 /* HTTPURLResponse+ParraDictionaryConvertible.swift */; }; + 006654102A7EBDB300CD04E6 /* NSURLRequest.CachePolicy+CustomStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0066540F2A7EBDB300CD04E6 /* NSURLRequest.CachePolicy+CustomStringConvertible.swift */; }; + 006654122A80573400CD04E6 /* URLRequest.Attribution+CustomStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 006654112A80573400CD04E6 /* URLRequest.Attribution+CustomStringConvertible.swift */; }; + 006654142A8066BC00CD04E6 /* ParraSessionUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 006654132A8066BC00CD04E6 /* ParraSessionUpload.swift */; }; + 006654392A89AFB000CD04E6 /* ParraSessionUploadGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 006654382A89AFB000CD04E6 /* ParraSessionUploadGenerator.swift */; }; + 0066543B2A8A5F0E00CD04E6 /* LoggerFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0066543A2A8A5F0E00CD04E6 /* LoggerFormatters.swift */; }; 006DE9802A472F1B00521F5D /* Parra+Push.swift in Sources */ = {isa = PBXBuildFile; fileRef = 006DE97F2A472F1B00521F5D /* Parra+Push.swift */; }; 006DE9822A472F9C00521F5D /* Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 006DE9812A472F9C00521F5D /* Endpoint.swift */; }; - 006DE9862A47390700521F5D /* ParraGlobalState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 006DE9852A47390600521F5D /* ParraGlobalState.swift */; }; - 006DE9882A47547900521F5D /* ParraWrappedLogMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 006DE9872A47547900521F5D /* ParraWrappedLogMessage.swift */; }; - 00707D1727F884BB004E9567 /* ParraNetworkManagerTests+Mocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00707D1627F884BB004E9567 /* ParraNetworkManagerTests+Mocks.swift */; }; + 006DE9862A47390700521F5D /* ParraState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 006DE9852A47390600521F5D /* ParraState.swift */; }; + 006DE9882A47547900521F5D /* ParraLazyLogParam.swift in Sources */ = {isa = PBXBuildFile; fileRef = 006DE9872A47547900521F5D /* ParraLazyLogParam.swift */; }; 00707D1B27FA0A07004E9567 /* URLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00707D1A27FA0A07004E9567 /* URLSession.swift */; }; - 00731EFB2A182639004010A3 /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00731EFA2A182639004010A3 /* Task.swift */; }; + 00719DB82AF69CAA00310E25 /* Parra.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 001B7AF227DE3A7600DEEECD /* Parra.framework */; }; + 00731EFB2A182639004010A3 /* Task+sleep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00731EFA2A182639004010A3 /* Task+sleep.swift */; }; + 007868992AAE4DE300864CFE /* ParraLogEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 007868982AAE4DE300864CFE /* ParraLogEvent.swift */; }; + 0078689D2AAE54F800864CFE /* ParraSessionEventSyncPriority.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0078689C2AAE54F800864CFE /* ParraSessionEventSyncPriority.swift */; }; + 007868A32AAE84CA00864CFE /* ParraSessionEventContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 007868A22AAE84CA00864CFE /* ParraSessionEventContext.swift */; }; + 007ED4672A6BFBDC0077E446 /* ParraLoggerContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 007ED4652A6BFBDC0077E446 /* ParraLoggerContext.swift */; }; + 007ED4682A6BFBDC0077E446 /* ParraLoggerContext+ParraDictionaryConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 007ED4662A6BFBDC0077E446 /* ParraLoggerContext+ParraDictionaryConvertible.swift */; }; + 007ED46A2A6BFBEA0077E446 /* ParraLoggerThreadInfo+ParraDictionaryConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 007ED4692A6BFBEA0077E446 /* ParraLoggerThreadInfo+ParraDictionaryConvertible.swift */; }; + 007ED46D2A6BFBF50077E446 /* ParraLogProcessedData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 007ED46B2A6BFBF50077E446 /* ParraLogProcessedData.swift */; }; + 007ED46E2A6BFBF50077E446 /* ParraLogProcessedData+ParraDictionaryConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 007ED46C2A6BFBF50077E446 /* ParraLogProcessedData+ParraDictionaryConvertible.swift */; }; + 007ED4702A6BFC020077E446 /* Logger+scope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 007ED46F2A6BFC020077E446 /* Logger+scope.swift */; }; + 007ED4722A6C2C810077E446 /* ParraWrappedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 007ED4712A6C2C810077E446 /* ParraWrappedEvent.swift */; }; + 007ED4742A6C458A0077E446 /* ParraSessionEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 007ED4732A6C458A0077E446 /* ParraSessionEvent.swift */; }; + 007ED4762A6C8BDF0077E446 /* CallStackParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 007ED4752A6C8BDF0077E446 /* CallStackParser.swift */; }; + 007ED4802A6C9D370077E446 /* CallStackFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = 007ED47F2A6C9D370077E446 /* CallStackFrame.swift */; }; + 0082DFBD2A535FDD00E7A91D /* Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0082DFBC2A535FDD00E7A91D /* Data.swift */; }; + 0082DFC12A53736F00E7A91D /* MockParraNetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0082DFC02A53736F00E7A91D /* MockParraNetworkManager.swift */; }; + 0082DFC42A53745F00E7A91D /* Parra+AuthenticationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00320A5A27ED4EF0001EB323 /* Parra+AuthenticationTests.swift */; }; + 0082DFC62A5374BD00E7A91D /* MockParra.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0082DFC52A5374BD00E7A91D /* MockParra.swift */; }; + 0082DFC72A53883D00E7A91D /* Parra+PushTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BD2FFF2A47A70800B4BEB2 /* Parra+PushTests.swift */; }; + 0082DFC92A538A5400E7A91D /* ParraAuthenticationProviderType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0082DFC82A538A5400E7A91D /* ParraAuthenticationProviderType.swift */; }; + 0082DFCA2A53939A00E7A91D /* ParraSyncManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001559B227E7EACC006450BE /* ParraSyncManagerTests.swift */; }; + 0082DFCC2A53B6F100E7A91D /* ParraSessionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0082DFCB2A53B6F100E7A91D /* ParraSessionManagerTests.swift */; }; + 0082DFCE2A53BA6700E7A91D /* Date+now.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0082DFCD2A53BA6700E7A91D /* Date+now.swift */; }; 0084014827D560B900E2B0ED /* ParraDemoTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0084014727D560B900E2B0ED /* ParraDemoTableViewController.swift */; }; 0084014A27D5613A00E2B0ED /* ParraCardsInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0084014927D5613A00E2B0ED /* ParraCardsInView.swift */; }; - 008878122979A3E20087AC1D /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 008878112979A3E20087AC1D /* UIApplication.swift */; }; - 00901F2D2991E3430071647F /* Encodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00901F2C2991E3430071647F /* Encodable.swift */; }; + 0087CA4F2A54668B007D72FA /* ParraUrlSessionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA4E2A54668B007D72FA /* ParraUrlSessionDelegate.swift */; }; + 0087CA512A5466A6007D72FA /* ParraNetworkManagerUrlSessionDelegateProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA502A5466A6007D72FA /* ParraNetworkManagerUrlSessionDelegateProxy.swift */; }; + 0087CA532A5466CF007D72FA /* ParraNetworkManager+ParraUrlSessionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA522A5466CF007D72FA /* ParraNetworkManager+ParraUrlSessionDelegate.swift */; }; + 0087CA552A54670A007D72FA /* EmptyJsonObjects.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA542A54670A007D72FA /* EmptyJsonObjects.swift */; }; + 0087CA662A56114C007D72FA /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA652A56114C007D72FA /* Logger.swift */; }; + 0087CA682A561F72007D72FA /* Logger+Levels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA672A561F72007D72FA /* Logger+Levels.swift */; }; + 0087CA6E2A57B308007D72FA /* ParraLogMarker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA6D2A57B308007D72FA /* ParraLogMarker.swift */; }; + 0087CA702A57B331007D72FA /* ParraLoggerBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA6F2A57B331007D72FA /* ParraLoggerBackend.swift */; }; + 0087CA722A57B343007D72FA /* ParraLoggerCallSiteContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA712A57B343007D72FA /* ParraLoggerCallSiteContext.swift */; }; + 0087CA742A57B38E007D72FA /* ParraLogMeasurementFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA732A57B38E007D72FA /* ParraLogMeasurementFormat.swift */; }; + 0087CA762A57B3C0007D72FA /* Logger+Timers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA752A57B3C0007D72FA /* Logger+Timers.swift */; }; + 0087CA7A2A58FAA2007D72FA /* Logger+StaticLevels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA792A58FAA2007D72FA /* Logger+StaticLevels.swift */; }; + 0087CA7C2A599916007D72FA /* ParraSessionManager+LogFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA7B2A599916007D72FA /* ParraSessionManager+LogFormatters.swift */; }; + 0087CA7E2A5999D7007D72FA /* ParraSessionManager+LogProcessors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA7D2A5999D7007D72FA /* ParraSessionManager+LogProcessors.swift */; }; + 0087CA832A599FDD007D72FA /* ParraLogStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA822A599FDD007D72FA /* ParraLogStringConvertible.swift */; }; + 0087CA852A59A046007D72FA /* QualityOfService+ParraLogDescription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA842A59A046007D72FA /* QualityOfService+ParraLogDescription.swift */; }; + 0087CA872A59A0D5007D72FA /* ParraLoggerThreadInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA862A59A0D5007D72FA /* ParraLoggerThreadInfo.swift */; }; + 0087CA8A2A59B3F9007D72FA /* ParraLoggerTimestampStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA892A59B3F9007D72FA /* ParraLoggerTimestampStyle.swift */; }; + 0087CA8C2A59B408007D72FA /* ParraLoggerLevelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA8B2A59B407007D72FA /* ParraLoggerLevelStyle.swift */; }; + 0087CA8E2A59B41C007D72FA /* ParraLoggerCallSiteStyleOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA8D2A59B41C007D72FA /* ParraLoggerCallSiteStyleOptions.swift */; }; + 0087CA902A59B435007D72FA /* ParraLoggerOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA8F2A59B435007D72FA /* ParraLoggerOptions.swift */; }; + 0087CA922A59BA51007D72FA /* ParraLoggerEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA912A59BA51007D72FA /* ParraLoggerEnvironment.swift */; }; + 0087CA942A59C0FB007D72FA /* ParraLoggerConsoleFormatOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA932A59C0FB007D72FA /* ParraLoggerConsoleFormatOption.swift */; }; + 0087CA962A59D82D007D72FA /* UIDeviceBatteryState+ParraLogStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA952A59D82D007D72FA /* UIDeviceBatteryState+ParraLogStringConvertible.swift */; }; + 0087CA982A59DEC8007D72FA /* ProcessInfoThermalState+ParraLogStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA972A59DEC8007D72FA /* ProcessInfoThermalState+ParraLogStringConvertible.swift */; }; + 0087CA9A2A59DFCC007D72FA /* ProcessInfoPowerState+ParraLogStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA992A59DFCC007D72FA /* ProcessInfoPowerState+ParraLogStringConvertible.swift */; }; + 0087CA9C2A59E166007D72FA /* URL+diskUsage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA9B2A59E166007D72FA /* URL+diskUsage.swift */; }; + 0087CAA02A59E4F1007D72FA /* ParraSanitizedDictionaryConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CA9F2A59E4F1007D72FA /* ParraSanitizedDictionaryConvertible.swift */; }; + 0087CAA22A59E507007D72FA /* ParraDiskUsage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CAA12A59E507007D72FA /* ParraDiskUsage.swift */; }; + 0087CAA42A59E51B007D72FA /* ParraDiskUsage+ParraSessionParamDictionaryConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CAA32A59E51B007D72FA /* ParraDiskUsage+ParraSessionParamDictionaryConvertible.swift */; }; + 0087CAAB2A59F3B4007D72FA /* ParraLogData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CAAA2A59F3B4007D72FA /* ParraLogData.swift */; }; + 0087CAAD2A5A2B1A007D72FA /* ParraLogMarkerMeasurement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0087CAAC2A5A2B1A007D72FA /* ParraLogMarkerMeasurement.swift */; }; + 008878122979A3E20087AC1D /* UIApplicationState+ParraLogStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 008878112979A3E20087AC1D /* UIApplicationState+ParraLogStringConvertible.swift */; }; + 00916DC02ACCEE7600B4856F /* MockedParraTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00916DBF2ACCEE7600B4856F /* MockedParraTestCase.swift */; }; + 00916DC22AD2EFCD00B4856F /* SignpostTestObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00916DC12AD2EFCD00B4856F /* SignpostTestObserver.swift */; }; + 00916DC82AD35D8700B4856F /* ParraInstanceConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00916DC72AD35D8700B4856F /* ParraInstanceConfiguration.swift */; }; + 00916DCA2AD35D9600B4856F /* ParraInstanceStorageConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00916DC92AD35D9600B4856F /* ParraInstanceStorageConfiguration.swift */; }; + 00916DCC2AD35D9D00B4856F /* ParraInstanceNetworkConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00916DCB2AD35D9D00B4856F /* ParraInstanceNetworkConfiguration.swift */; }; 00A60B992932D35E00B7168A /* ParraConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00A60B982932D35E00B7168A /* ParraConfiguration.swift */; }; 00A60B9B2932D37000B7168A /* ParraAuthenticationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00A60B9A2932D37000B7168A /* ParraAuthenticationProvider.swift */; }; - 00BD2FEE2A4771D700B4BEB2 /* SyncState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BD2FED2A4771D700B4BEB2 /* SyncState.swift */; }; + 00B42EA72ADE176E00F365A5 /* Parra.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 00B42EA62ADE176E00F365A5 /* Parra.xctestplan */; }; + 00B42EB12ADE1BCB00F365A5 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00B42EB02ADE1BCB00F365A5 /* AppDelegate.swift */; }; + 00B42EB32ADE1BCB00F365A5 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00B42EB22ADE1BCB00F365A5 /* SceneDelegate.swift */; }; + 00B42EB82ADE1BCB00F365A5 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 00B42EB62ADE1BCB00F365A5 /* Main.storyboard */; }; + 00B42EBA2ADE1BCC00F365A5 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 00B42EB92ADE1BCC00F365A5 /* Assets.xcassets */; }; + 00B42EBD2ADE1BCC00F365A5 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 00B42EBB2ADE1BCC00F365A5 /* LaunchScreen.storyboard */; }; + 00BC73162AC27BD300605873 /* Documentation.docc in Sources */ = {isa = PBXBuildFile; fileRef = 00BC73152AC27BD300605873 /* Documentation.docc */; }; + 00BD2FEE2A4771D700B4BEB2 /* ParraSyncState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BD2FED2A4771D700B4BEB2 /* ParraSyncState.swift */; }; 00BD2FF22A4776E700B4BEB2 /* NetworkManagerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BD2FF12A4776E700B4BEB2 /* NetworkManagerType.swift */; }; 00BD2FF42A47771300B4BEB2 /* AuthenticatedRequestAttributeOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BD2FF32A47771300B4BEB2 /* AuthenticatedRequestAttributeOptions.swift */; }; 00BD2FF62A47772A00B4BEB2 /* AuthenticatedRequestResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BD2FF52A47772A00B4BEB2 /* AuthenticatedRequestResult.swift */; }; @@ -147,34 +231,65 @@ 00BD2FFA2A47778E00B4BEB2 /* NotificationCenterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BD2FF92A47778E00B4BEB2 /* NotificationCenterType.swift */; }; 00BD2FFC2A477D7700B4BEB2 /* ParraNotificationCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BD2FFB2A477D7700B4BEB2 /* ParraNotificationCenter.swift */; }; 00BD2FFE2A479BBA00B4BEB2 /* ParraConfigState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BD2FFD2A479BBA00B4BEB2 /* ParraConfigState.swift */; }; - 00BD30002A47A70800B4BEB2 /* Parra+PushTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BD2FFF2A47A70800B4BEB2 /* Parra+PushTests.swift */; }; 00BDB2AF28BC24E700C16649 /* ParraCredentialTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BDB2AE28BC24E700C16649 /* ParraCredentialTests.swift */; }; - 00BDB2B128BD822700C16649 /* URLRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BDB2B028BD822700C16649 /* URLRequest.swift */; }; + 00BDB2B128BD822700C16649 /* URLRequest+headers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BDB2B028BD822700C16649 /* URLRequest+headers.swift */; }; + 00BF36C72B59AB000097905C /* ParraFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BF36C62B59AB000097905C /* ParraFixture.swift */; }; + 00BF36C92B59AB190097905C /* FeedbackFormSelectFieldData+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BF36C82B59AB190097905C /* FeedbackFormSelectFieldData+Fixtures.swift */; }; + 00BF36CB2B59B01D0097905C /* FeedbackFormField+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BF36CA2B59B01D0097905C /* FeedbackFormField+Fixtures.swift */; }; + 00BF36CD2B59BBDE0097905C /* FeedbackFormTextFieldData+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BF36CC2B59BBDE0097905C /* FeedbackFormTextFieldData+Fixtures.swift */; }; + 00BF36D12B5AC3EE0097905C /* FormFieldWithState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BF36D02B5AC3EE0097905C /* FormFieldWithState.swift */; }; + 00BF36D32B5ACA460097905C /* FeedbackFormViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BF36D22B5ACA460097905C /* FeedbackFormViewState.swift */; }; + 00BF36D52B5ACAD20097905C /* FormFieldState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BF36D42B5ACAD20097905C /* FormFieldState.swift */; }; + 00C00ABB2A8A6BC2003B21D3 /* SessionStorageContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00C00ABA2A8A6BC2003B21D3 /* SessionStorageContext.swift */; }; + 00C00ABD2A8A6BD1003B21D3 /* SessionReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00C00ABC2A8A6BD1003B21D3 /* SessionReader.swift */; }; + 00D04C5F2AD429620024CA4E /* SessionReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00D04C5E2AD429620024CA4E /* SessionReaderTests.swift */; }; + 00D04C612AD437850024CA4E /* ParraBaseMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00D04C602AD437850024CA4E /* ParraBaseMock.swift */; }; 00DA7FE5295C95C400109081 /* Syncable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00DA7FE4295C95C400109081 /* Syncable.swift */; }; 00DA7FE8295CAB8F00109081 /* AnyCodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00DA7FE7295CAB8F00109081 /* AnyCodable.swift */; }; 00DA7FEA295CAB9700109081 /* AnyEncodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00DA7FE9295CAB9700109081 /* AnyEncodable.swift */; }; 00DA7FEC295CAB9E00109081 /* AnyDecodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00DA7FEB295CAB9E00109081 /* AnyDecodable.swift */; }; - 00DA7FF0295DD28700109081 /* Parra+Analytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00DA7FEF295DD28700109081 /* Parra+Analytics.swift */; }; + 00DA7FF0295DD28700109081 /* Parra+PublicAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00DA7FEF295DD28700109081 /* Parra+PublicAnalytics.swift */; }; 00DA7FF3295DD2D500109081 /* ParraSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00DA7FF2295DD2D500109081 /* ParraSession.swift */; }; - 00DA7FF5295DD2EC00109081 /* ParraSessionEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00DA7FF4295DD2EC00109081 /* ParraSessionEvent.swift */; }; - 00DA7FF7295DD2FD00109081 /* ParraSessionEventType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00DA7FF6295DD2FD00109081 /* ParraSessionEventType.swift */; }; - 00DA7FF9295F2A1600109081 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00DA7FF8295F2A1600109081 /* URL.swift */; }; + 00DA7FF9295F2A1600109081 /* URL+safePaths.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00DA7FF8295F2A1600109081 /* URL+safePaths.swift */; }; 00DA7FFC296367D400109081 /* RequestConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00DA7FFB296367D400109081 /* RequestConfig.swift */; }; 00DA7FFE296367E400109081 /* ParraHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00DA7FFD296367E400109081 /* ParraHeader.swift */; }; 00DECFF9274C69F800DAF301 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00DECFF8274C69F800DAF301 /* AppDelegate.swift */; }; 00DED000274C69F800DAF301 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 00DECFFE274C69F800DAF301 /* Main.storyboard */; }; 00DED005274C69FA00DAF301 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 00DED003274C69FA00DAF301 /* LaunchScreen.storyboard */; }; - 00DEF7262A3FE659006B0DF3 /* UIDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00DEF7252A3FE659006B0DF3 /* UIDevice.swift */; }; + 00DEF7262A3FE659006B0DF3 /* UIDevice+modelCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00DEF7252A3FE659006B0DF3 /* UIDevice+modelCode.swift */; }; 00E15A1F2929B7C10049C2C7 /* ParraSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E15A1E2929B7C10049C2C7 /* ParraSessionManager.swift */; }; - 00E15A212929BC790049C2C7 /* UIDeviceOrientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E15A202929BC790049C2C7 /* UIDeviceOrientation.swift */; }; + 00E15A212929BC790049C2C7 /* UIDeviceOrientation+ParraLogStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E15A202929BC790049C2C7 /* UIDeviceOrientation+ParraLogStringConvertible.swift */; }; 00E15A23292AFB2B0049C2C7 /* SessionStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E15A22292AFB2B0049C2C7 /* SessionStorage.swift */; }; 00E15A25293266470049C2C7 /* ParraCardsInModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E15A24293266470049C2C7 /* ParraCardsInModal.swift */; }; 00E15A29293269100049C2C7 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E15A28293269100049C2C7 /* UIViewController.swift */; }; - 00E15A2B29326FBE0049C2C7 /* UIWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E15A2A29326FBE0049C2C7 /* UIWindow.swift */; }; + 00E15A2B29326FBE0049C2C7 /* UIWindow+topViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E15A2A29326FBE0049C2C7 /* UIWindow+topViewController.swift */; }; 00E15A2D293276940049C2C7 /* Thread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E15A2C293276940049C2C7 /* Thread.swift */; }; - 00E7957C29A05CBD003FE68D /* ParraLoggerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E7957B29A05CBD003FE68D /* ParraLoggerConfig.swift */; }; + 00E269E42A523000001A0F50 /* ParraFeedbackPopupState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E269E32A523000001A0F50 /* ParraFeedbackPopupState.swift */; }; + 00E269E82A523CD5001A0F50 /* ParraConfigurationOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E269E72A523CD5001A0F50 /* ParraConfigurationOption.swift */; }; + 00E269EA2A524842001A0F50 /* ParraModuleStateAccessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E269E92A524842001A0F50 /* ParraModuleStateAccessor.swift */; }; + 00E269EC2A5257C0001A0F50 /* URLRequestHeaderField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E269EB2A5257C0001A0F50 /* URLRequestHeaderField.swift */; }; + 00E269EE2A5257DC001A0F50 /* AuthorizationType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E269ED2A5257DC001A0F50 /* AuthorizationType.swift */; }; + 00E269F02A525803001A0F50 /* Mimetype.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E269EF2A525803001A0F50 /* Mimetype.swift */; }; + 00E269F62A526776001A0F50 /* Endpoint+Mocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E269F42A526770001A0F50 /* Endpoint+Mocks.swift */; }; + 00E269F82A530771001A0F50 /* XCTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E269F72A530771001A0F50 /* XCTestCase.swift */; }; + 00E269FA2A530D69001A0F50 /* ParraEndpoint+CaseIterable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E269F92A530D69001A0F50 /* ParraEndpoint+CaseIterable.swift */; }; + 00E269FE2A5310D5001A0F50 /* URLSessionDataTaskType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E269FD2A5310D5001A0F50 /* URLSessionDataTaskType.swift */; }; + 00E26A002A53110F001A0F50 /* MockURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E269FF2A53110F001A0F50 /* MockURLSession.swift */; }; + 00E26A032A53120F001A0F50 /* TestData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E26A022A53120F001A0F50 /* TestData.swift */; }; + 00E26A052A53132E001A0F50 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E26A042A53132E001A0F50 /* String.swift */; }; + 00E26A072A534334001A0F50 /* ParraState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E26A062A534334001A0F50 /* ParraState.swift */; }; 00E7957E29A05CD6003FE68D /* ParraLogLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E7957D29A05CD6003FE68D /* ParraLogLevel.swift */; }; - 00E7958029A12E29003FE68D /* ParraDefaultLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E7957F29A12E29003FE68D /* ParraDefaultLogger.swift */; }; + 00EAE2F52A9BC3DB003FB41C /* FileHandleType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00EAE2F42A9BC3DB003FB41C /* FileHandleType.swift */; }; + 00EAE2F72A9BE71F003FB41C /* URL+helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00EAE2F62A9BE71F003FB41C /* URL+helpers.swift */; }; + 00EAE2F92A9F7D42003FB41C /* ParraSessionEventTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00EAE2F82A9F7D42003FB41C /* ParraSessionEventTarget.swift */; }; + 00EAE2FB2AA0C196003FB41C /* ParraSessionGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00EAE2FA2AA0C196003FB41C /* ParraSessionGenerator.swift */; }; + 00EAE2FE2AA0C8A2003FB41C /* ParraSessionGeneratorType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00EAE2FD2AA0C8A2003FB41C /* ParraSessionGeneratorType.swift */; }; + 00EBCE082AA4C1E300887639 /* ParraLogContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00EBCE072AA4C1E300887639 /* ParraLogContext.swift */; }; + 00EBCE0A2AA4C1EE00887639 /* ParraLoggerScopeType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00EBCE092AA4C1EE00887639 /* ParraLoggerScopeType.swift */; }; + 00EBCE0D2AA4CEEF00887639 /* ParraLoggerStackSymbols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00EBCE0C2AA4CEEF00887639 /* ParraLoggerStackSymbols.swift */; }; + 00EBCE0F2AA4CF0C00887639 /* QualityOfService+Codable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00EBCE0E2AA4CF0C00887639 /* QualityOfService+Codable.swift */; }; + 00EBCE142AA4D58F00887639 /* ParraLogLevel+ConsoleOutput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00EBCE132AA4D58F00887639 /* ParraLogLevel+ConsoleOutput.swift */; }; + 00EBCE162AA4F0D100887639 /* ParraLoggerExtraStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00EBCE152AA4F0D100887639 /* ParraLoggerExtraStyle.swift */; }; 00F5AAF127FA819200A94C85 /* ParraCardsInTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00F5AAF027FA819200A94C85 /* ParraCardsInTableView.swift */; }; 00F5AAF428023EF300A94C85 /* GeneratedTypes+Swift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00F5AAF328023EF300A94C85 /* GeneratedTypes+Swift.swift */; }; 00FD310D28329FF00080689C /* ParraCardsInCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00FD310C28329FF00080689C /* ParraCardsInCollectionView.swift */; }; @@ -188,20 +303,27 @@ remoteGlobalIDString = 001B7AF127DE3A7600DEEECD; remoteInfo = Parra; }; - 001B7AFD27DE3A7600DEEECD /* PBXContainerItemProxy */ = { + 001B7B0327DE3A7600DEEECD /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 00DECFED274C69F800DAF301 /* Project object */; proxyType = 1; - remoteGlobalIDString = 00DECFF4274C69F800DAF301; - remoteInfo = Demo; + remoteGlobalIDString = 001B7AF127DE3A7600DEEECD; + remoteInfo = Parra; }; - 001B7B0327DE3A7600DEEECD /* PBXContainerItemProxy */ = { + 00719DBA2AF69CAA00310E25 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 00DECFED274C69F800DAF301 /* Project object */; proxyType = 1; remoteGlobalIDString = 001B7AF127DE3A7600DEEECD; remoteInfo = Parra; }; + 00BF36C02B56CC9B0097905C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 00DECFED274C69F800DAF301 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 001B7AF827DE3A7600DEEECD; + remoteInfo = ParraTests; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -228,8 +350,7 @@ 00120F002753E9A400CA3247 /* ParraCredential.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraCredential.swift; sourceTree = ""; }; 001286F3277D53AE0090CDB5 /* SelectableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableButton.swift; sourceTree = ""; }; 001286F5277D54480090CDB5 /* CAShapeLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CAShapeLayer.swift; sourceTree = ""; }; - 001286FC277FE2320090CDB5 /* Pacifico-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Pacifico-Regular.ttf"; sourceTree = ""; }; - 001286FE277FE3180090CDB5 /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; + 001286FE277FE3180090CDB5 /* UIFont+registration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+registration.swift"; sourceTree = ""; }; 001287002780C0C70090CDB5 /* ParraFeedbackDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraFeedbackDataManager.swift; sourceTree = ""; }; 0015599627E2BEBF006450BE /* ParraEmptyCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraEmptyCardView.swift; sourceTree = ""; }; 0015599B27E40B13006450BE /* FileSystemStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSystemStorageTests.swift; sourceTree = ""; }; @@ -238,21 +359,20 @@ 001559A327E412FD006450BE /* UserDefaultsStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsStorageTests.swift; sourceTree = ""; }; 001559A727E417D4006450BE /* ParraLoggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLoggerTests.swift; sourceTree = ""; }; 001559A927E41A8A006450BE /* ParraStorageModuleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraStorageModuleTests.swift; sourceTree = ""; }; - 001559AD27E7E2F3006450BE /* Parra.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = Parra.xctestplan; sourceTree = ""; }; 001559AE27E7E76C006450BE /* CredentialStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialStorageTests.swift; sourceTree = ""; }; 001559B027E7EAC0006450BE /* ParraDataManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraDataManagerTests.swift; sourceTree = ""; }; 001559B227E7EACC006450BE /* ParraSyncManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraSyncManagerTests.swift; sourceTree = ""; }; 001559B427E7EAD9006450BE /* ParraNetworkManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraNetworkManagerTests.swift; sourceTree = ""; }; - 0018A60D28D77FF2008CC97E /* URLComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLComponents.swift; sourceTree = ""; }; + 0018A60D28D77FF2008CC97E /* URLComponents+queryItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLComponents+queryItems.swift"; sourceTree = ""; }; 0019CBC228F639040015E703 /* ParraQuestionAppArea.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraQuestionAppArea.swift; sourceTree = ""; }; 0019CBC428FAE0E80015E703 /* FailableDecodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailableDecodable.swift; sourceTree = ""; }; 0019CBC628FB1B580015E703 /* ParraImageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraImageButton.swift; sourceTree = ""; }; - 0019CBC828FB37BC0015E703 /* Sequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sequence.swift; sourceTree = ""; }; + 0019CBC828FB37BC0015E703 /* Sequence+asyncMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sequence+asyncMap.swift"; sourceTree = ""; }; 001B7A0A27DD266500DEEECD /* Parra.podspec */ = {isa = PBXFileReference; lastKnownFileType = text; path = Parra.podspec; sourceTree = ""; }; 001B7A8B27DD883500DEEECD /* HttpMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HttpMethod.swift; sourceTree = ""; }; 001B7A8D27DD8B5500DEEECD /* ParraModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraModule.swift; sourceTree = ""; }; 001B7A8F27DD8D6700DEEECD /* ParraFeedback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraFeedback.swift; sourceTree = ""; }; - 001B7A9127DD8DCE00DEEECD /* Parra+Endpoints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Parra+Endpoints.swift"; sourceTree = ""; }; + 001B7A9127DD8DCE00DEEECD /* ParraNetworkManager+Endpoints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ParraNetworkManager+Endpoints.swift"; sourceTree = ""; }; 001B7AF227DE3A7600DEEECD /* Parra.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Parra.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 001B7AF427DE3A7600DEEECD /* Parra.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Parra.h; sourceTree = ""; }; 001B7AF927DE3A7600DEEECD /* ParraTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ParraTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -267,7 +387,22 @@ 001B7B8327DE6E2400DEEECD /* ParraFeedback+Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ParraFeedback+Constants.swift"; sourceTree = ""; }; 001B7B8727DE712200DEEECD /* ParraStorageModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraStorageModule.swift; sourceTree = ""; }; 001B7B8D27DE806100DEEECD /* DataStorageMedium.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataStorageMedium.swift; sourceTree = ""; }; + 00214C692AB3E2BC001C5313 /* ParraSanitizedDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraSanitizedDictionary.swift; sourceTree = ""; }; + 00214C6B2AB3E3A0001C5313 /* ParraDataSanitizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraDataSanitizer.swift; sourceTree = ""; }; + 00214C6D2AB62510001C5313 /* String+prefixes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+prefixes.swift"; sourceTree = ""; }; + 0029AD2A2A48DCE100E30CCD /* LoggerHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerHelpers.swift; sourceTree = ""; }; + 0029AD2D2A48E11300E30CCD /* LoggerHelpersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerHelpersTests.swift; sourceTree = ""; }; + 0029AD2F2A490CDB00E30CCD /* Parra+InternalAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Parra+InternalAnalytics.swift"; sourceTree = ""; }; + 0029AD322A49256300E30CCD /* ParraEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraEvent.swift; sourceTree = ""; }; + 0029AD362A4925C000E30CCD /* ParraStandardEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraStandardEvent.swift; sourceTree = ""; }; + 0029AD382A4925D000E30CCD /* ParraInternalEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraInternalEvent.swift; sourceTree = ""; }; + 0029AD3A2A49290A00E30CCD /* StringManipulators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringManipulators.swift; sourceTree = ""; }; 00320A5A27ED4EF0001EB323 /* Parra+AuthenticationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Parra+AuthenticationTests.swift"; sourceTree = ""; }; + 003621D62B5B563C009EFE53 /* ParraLogo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLogo.swift; sourceTree = ""; }; + 003621DA2B5B569F009EFE53 /* ParraLogoType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLogoType.swift; sourceTree = ""; }; + 003621DC2B5B592D009EFE53 /* ParraAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = ParraAssets.xcassets; sourceTree = ""; }; + 003621E82B5C2784009EFE53 /* ParraLogoButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLogoButton.swift; sourceTree = ""; }; + 003621EA2B5C2A62009EFE53 /* FeedbackFormViewState+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedbackFormViewState+Fixtures.swift"; sourceTree = ""; }; 0036D5E127CACA100016DE97 /* ParraError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraError.swift; sourceTree = ""; }; 0036D5E327CB0EEA0016DE97 /* JSONEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONEncoder.swift; sourceTree = ""; }; 0036D5E527CB0FBD0016DE97 /* JSONDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONDecoder.swift; sourceTree = ""; }; @@ -280,13 +415,14 @@ 0036D60527D049EB0016DE97 /* ParraFeedbackDataManager+Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ParraFeedbackDataManager+Keys.swift"; sourceTree = ""; }; 0036D60727D051060016DE97 /* ParraCardView+Transitions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ParraCardView+Transitions.swift"; sourceTree = ""; }; 0036D60927D051B20016DE97 /* ParraCardView+Layout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ParraCardView+Layout.swift"; sourceTree = ""; }; - 0036D60B27D053160016DE97 /* FileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManager.swift; sourceTree = ""; }; + 0036D60B27D053160016DE97 /* FileManager+safePaths.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+safePaths.swift"; sourceTree = ""; }; 003C214627DE855A001CAE03 /* ItemStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemStorage.swift; sourceTree = ""; }; 003C216B27DED58C001CAE03 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 003C216D27DEE77D001CAE03 /* Demo-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Demo-Bridging-Header.h"; sourceTree = ""; }; 003D2A74274C77BD001E067E /* Parra.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Parra.swift; sourceTree = ""; }; 003D2A76274C84A3001E067E /* ParraQuestionCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraQuestionCardView.swift; sourceTree = ""; }; 003E3F8B2A2E3B25007373C2 /* ParraFeedbackFormViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraFeedbackFormViewController.swift; sourceTree = ""; }; + 0040DEAC2AA226A3007E2190 /* UIViewController+defaultLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+defaultLogger.swift"; sourceTree = ""; }; 00497D7829F88CC4004EF5AB /* ParraSessionsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraSessionsResponse.swift; sourceTree = ""; }; 005D31E729A301DB001D0E0A /* ParraConfigurableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraConfigurableView.swift; sourceTree = ""; }; 005D31EC29A8561D001D0E0A /* QuestionAnswer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionAnswer.swift; sourceTree = ""; }; @@ -302,7 +438,6 @@ 005D320629B52039001D0E0A /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; 005D320829B523E4001D0E0A /* UIEdgeInsets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIEdgeInsets.swift; sourceTree = ""; }; 005D320A29B530E8001D0E0A /* ParraBorderedTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraBorderedTextView.swift; sourceTree = ""; }; - 005E38A427FA1E0200F32F96 /* NetworkTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkTestHelpers.swift; sourceTree = ""; }; 005E38A627FA3A8700F32F96 /* Parra+Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Parra+Notifications.swift"; sourceTree = ""; }; 006153E12A2FCCD600ED4CBF /* ParraFeedbackFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraFeedbackFormView.swift; sourceTree = ""; }; 006153E32A31539900ED4CBF /* ParraFeedbackForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraFeedbackForm.swift; sourceTree = ""; }; @@ -311,35 +446,107 @@ 006153EC2A36823900ED4CBF /* ParraFeedbackFormFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraFeedbackFormFieldView.swift; sourceTree = ""; }; 006153EE2A36825500ED4CBF /* ParraFeedbackFormSelectFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraFeedbackFormSelectFieldView.swift; sourceTree = ""; }; 006153F02A36826400ED4CBF /* ParraFeedbackFormTextFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraFeedbackFormTextFieldView.swift; sourceTree = ""; }; + 006654092A7EAAFA00CD04E6 /* URLRequest+ParraDictionaryConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLRequest+ParraDictionaryConvertible.swift"; sourceTree = ""; }; + 0066540B2A7EAEF000CD04E6 /* ParraErrorWithExtra.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraErrorWithExtra.swift; sourceTree = ""; }; + 0066540D2A7EBAAF00CD04E6 /* HTTPURLResponse+ParraDictionaryConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HTTPURLResponse+ParraDictionaryConvertible.swift"; sourceTree = ""; }; + 0066540F2A7EBDB300CD04E6 /* NSURLRequest.CachePolicy+CustomStringConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSURLRequest.CachePolicy+CustomStringConvertible.swift"; sourceTree = ""; }; + 006654112A80573400CD04E6 /* URLRequest.Attribution+CustomStringConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLRequest.Attribution+CustomStringConvertible.swift"; sourceTree = ""; }; + 006654132A8066BC00CD04E6 /* ParraSessionUpload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraSessionUpload.swift; sourceTree = ""; }; + 006654382A89AFB000CD04E6 /* ParraSessionUploadGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraSessionUploadGenerator.swift; sourceTree = ""; }; + 0066543A2A8A5F0E00CD04E6 /* LoggerFormatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerFormatters.swift; sourceTree = ""; }; 006DE97F2A472F1B00521F5D /* Parra+Push.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Parra+Push.swift"; sourceTree = ""; }; 006DE9812A472F9C00521F5D /* Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Endpoint.swift; sourceTree = ""; }; 006DE9832A47332100521F5D /* Demo.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Demo.entitlements; sourceTree = ""; }; - 006DE9852A47390600521F5D /* ParraGlobalState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraGlobalState.swift; sourceTree = ""; }; - 006DE9872A47547900521F5D /* ParraWrappedLogMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraWrappedLogMessage.swift; sourceTree = ""; }; - 00707D1627F884BB004E9567 /* ParraNetworkManagerTests+Mocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ParraNetworkManagerTests+Mocks.swift"; sourceTree = ""; }; + 006DE9852A47390600521F5D /* ParraState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraState.swift; sourceTree = ""; }; + 006DE9872A47547900521F5D /* ParraLazyLogParam.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLazyLogParam.swift; sourceTree = ""; }; 00707D1A27FA0A07004E9567 /* URLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSession.swift; sourceTree = ""; }; 00717C2C27F3E11100BE5608 /* ParraCardTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraCardTableViewCell.swift; sourceTree = ""; }; - 00731EFA2A182639004010A3 /* Task.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Task.swift; sourceTree = ""; }; + 00719DB72AF69B9E00310E25 /* TestRunner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TestRunner.entitlements; sourceTree = ""; }; + 00731EFA2A182639004010A3 /* Task+sleep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Task+sleep.swift"; sourceTree = ""; }; + 007868982AAE4DE300864CFE /* ParraLogEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLogEvent.swift; sourceTree = ""; }; + 0078689C2AAE54F800864CFE /* ParraSessionEventSyncPriority.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraSessionEventSyncPriority.swift; sourceTree = ""; }; + 007868A22AAE84CA00864CFE /* ParraSessionEventContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraSessionEventContext.swift; sourceTree = ""; }; + 007ED4652A6BFBDC0077E446 /* ParraLoggerContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParraLoggerContext.swift; sourceTree = ""; }; + 007ED4662A6BFBDC0077E446 /* ParraLoggerContext+ParraDictionaryConvertible.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ParraLoggerContext+ParraDictionaryConvertible.swift"; sourceTree = ""; }; + 007ED4692A6BFBEA0077E446 /* ParraLoggerThreadInfo+ParraDictionaryConvertible.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ParraLoggerThreadInfo+ParraDictionaryConvertible.swift"; sourceTree = ""; }; + 007ED46B2A6BFBF50077E446 /* ParraLogProcessedData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParraLogProcessedData.swift; sourceTree = ""; }; + 007ED46C2A6BFBF50077E446 /* ParraLogProcessedData+ParraDictionaryConvertible.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ParraLogProcessedData+ParraDictionaryConvertible.swift"; sourceTree = ""; }; + 007ED46F2A6BFC020077E446 /* Logger+scope.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Logger+scope.swift"; sourceTree = ""; }; + 007ED4712A6C2C810077E446 /* ParraWrappedEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraWrappedEvent.swift; sourceTree = ""; }; + 007ED4732A6C458A0077E446 /* ParraSessionEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraSessionEvent.swift; sourceTree = ""; }; + 007ED4752A6C8BDF0077E446 /* CallStackParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallStackParser.swift; sourceTree = ""; }; + 007ED47F2A6C9D370077E446 /* CallStackFrame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallStackFrame.swift; sourceTree = ""; }; 007F30EC29A189D1005731C4 /* ParraImageKindView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraImageKindView.swift; sourceTree = ""; }; 007F30EE29A18A87005731C4 /* ParraStarKindView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraStarKindView.swift; sourceTree = ""; }; 007F30F029A18B4E005731C4 /* ParraShortTextKindView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraShortTextKindView.swift; sourceTree = ""; }; 007F30F229A18B80005731C4 /* ParraBooleanKindView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraBooleanKindView.swift; sourceTree = ""; }; 007FBEF3277A34C700BD9E37 /* GeneratedTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneratedTypes.swift; sourceTree = ""; }; 007FBEF5277A700200BD9E37 /* libswift_Concurrency.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libswift_Concurrency.tbd; path = usr/lib/swift/libswift_Concurrency.tbd; sourceTree = SDKROOT; }; + 0082DFBC2A535FDD00E7A91D /* Data.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data.swift; sourceTree = ""; }; + 0082DFC02A53736F00E7A91D /* MockParraNetworkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockParraNetworkManager.swift; sourceTree = ""; }; + 0082DFC52A5374BD00E7A91D /* MockParra.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockParra.swift; sourceTree = ""; }; + 0082DFC82A538A5400E7A91D /* ParraAuthenticationProviderType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraAuthenticationProviderType.swift; sourceTree = ""; }; + 0082DFCB2A53B6F100E7A91D /* ParraSessionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraSessionManagerTests.swift; sourceTree = ""; }; + 0082DFCD2A53BA6700E7A91D /* Date+now.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+now.swift"; sourceTree = ""; }; 0084014727D560B900E2B0ED /* ParraDemoTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraDemoTableViewController.swift; sourceTree = ""; }; 0084014927D5613A00E2B0ED /* ParraCardsInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraCardsInView.swift; sourceTree = ""; }; 0084014C27D5A1B700E2B0ED /* CompletedCardDataStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletedCardDataStorage.swift; sourceTree = ""; }; 0084014E27D7030D00E2B0ED /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; - 008878112979A3E20087AC1D /* UIApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; }; - 00901F2C2991E3430071647F /* Encodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Encodable.swift; sourceTree = ""; }; + 0087CA4E2A54668B007D72FA /* ParraUrlSessionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraUrlSessionDelegate.swift; sourceTree = ""; }; + 0087CA502A5466A6007D72FA /* ParraNetworkManagerUrlSessionDelegateProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraNetworkManagerUrlSessionDelegateProxy.swift; sourceTree = ""; }; + 0087CA522A5466CF007D72FA /* ParraNetworkManager+ParraUrlSessionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ParraNetworkManager+ParraUrlSessionDelegate.swift"; sourceTree = ""; }; + 0087CA542A54670A007D72FA /* EmptyJsonObjects.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyJsonObjects.swift; sourceTree = ""; }; + 0087CA652A56114C007D72FA /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + 0087CA672A561F72007D72FA /* Logger+Levels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+Levels.swift"; sourceTree = ""; }; + 0087CA6D2A57B308007D72FA /* ParraLogMarker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLogMarker.swift; sourceTree = ""; }; + 0087CA6F2A57B331007D72FA /* ParraLoggerBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLoggerBackend.swift; sourceTree = ""; }; + 0087CA712A57B343007D72FA /* ParraLoggerCallSiteContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLoggerCallSiteContext.swift; sourceTree = ""; }; + 0087CA732A57B38E007D72FA /* ParraLogMeasurementFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLogMeasurementFormat.swift; sourceTree = ""; }; + 0087CA752A57B3C0007D72FA /* Logger+Timers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+Timers.swift"; sourceTree = ""; }; + 0087CA792A58FAA2007D72FA /* Logger+StaticLevels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+StaticLevels.swift"; sourceTree = ""; }; + 0087CA7B2A599916007D72FA /* ParraSessionManager+LogFormatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ParraSessionManager+LogFormatters.swift"; sourceTree = ""; }; + 0087CA7D2A5999D7007D72FA /* ParraSessionManager+LogProcessors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ParraSessionManager+LogProcessors.swift"; sourceTree = ""; }; + 0087CA822A599FDD007D72FA /* ParraLogStringConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLogStringConvertible.swift; sourceTree = ""; }; + 0087CA842A59A046007D72FA /* QualityOfService+ParraLogDescription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QualityOfService+ParraLogDescription.swift"; sourceTree = ""; }; + 0087CA862A59A0D5007D72FA /* ParraLoggerThreadInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLoggerThreadInfo.swift; sourceTree = ""; }; + 0087CA892A59B3F9007D72FA /* ParraLoggerTimestampStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLoggerTimestampStyle.swift; sourceTree = ""; }; + 0087CA8B2A59B407007D72FA /* ParraLoggerLevelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLoggerLevelStyle.swift; sourceTree = ""; }; + 0087CA8D2A59B41C007D72FA /* ParraLoggerCallSiteStyleOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLoggerCallSiteStyleOptions.swift; sourceTree = ""; }; + 0087CA8F2A59B435007D72FA /* ParraLoggerOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLoggerOptions.swift; sourceTree = ""; }; + 0087CA912A59BA51007D72FA /* ParraLoggerEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLoggerEnvironment.swift; sourceTree = ""; }; + 0087CA932A59C0FB007D72FA /* ParraLoggerConsoleFormatOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLoggerConsoleFormatOption.swift; sourceTree = ""; }; + 0087CA952A59D82D007D72FA /* UIDeviceBatteryState+ParraLogStringConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDeviceBatteryState+ParraLogStringConvertible.swift"; sourceTree = ""; }; + 0087CA972A59DEC8007D72FA /* ProcessInfoThermalState+ParraLogStringConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProcessInfoThermalState+ParraLogStringConvertible.swift"; sourceTree = ""; }; + 0087CA992A59DFCC007D72FA /* ProcessInfoPowerState+ParraLogStringConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProcessInfoPowerState+ParraLogStringConvertible.swift"; sourceTree = ""; }; + 0087CA9B2A59E166007D72FA /* URL+diskUsage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+diskUsage.swift"; sourceTree = ""; }; + 0087CA9F2A59E4F1007D72FA /* ParraSanitizedDictionaryConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraSanitizedDictionaryConvertible.swift; sourceTree = ""; }; + 0087CAA12A59E507007D72FA /* ParraDiskUsage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraDiskUsage.swift; sourceTree = ""; }; + 0087CAA32A59E51B007D72FA /* ParraDiskUsage+ParraSessionParamDictionaryConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ParraDiskUsage+ParraSessionParamDictionaryConvertible.swift"; sourceTree = ""; }; + 0087CAAA2A59F3B4007D72FA /* ParraLogData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLogData.swift; sourceTree = ""; }; + 0087CAAC2A5A2B1A007D72FA /* ParraLogMarkerMeasurement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLogMarkerMeasurement.swift; sourceTree = ""; }; + 008878112979A3E20087AC1D /* UIApplicationState+ParraLogStringConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplicationState+ParraLogStringConvertible.swift"; sourceTree = ""; }; 00901F302999AC940071647F /* ParraCheckboxKindView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraCheckboxKindView.swift; sourceTree = ""; }; 00901F332999ACAB0071647F /* ParraChoiceKindView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraChoiceKindView.swift; sourceTree = ""; }; 00901F352999ACBE0071647F /* ParraRatingKindView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraRatingKindView.swift; sourceTree = ""; }; 00901F372999ACC70071647F /* ParraLongTextKindView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLongTextKindView.swift; sourceTree = ""; }; 00901F392999ACDF0071647F /* ParraQuestionKindView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraQuestionKindView.swift; sourceTree = ""; }; + 00916DBF2ACCEE7600B4856F /* MockedParraTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockedParraTestCase.swift; sourceTree = ""; }; + 00916DC12AD2EFCD00B4856F /* SignpostTestObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignpostTestObserver.swift; sourceTree = ""; }; + 00916DC72AD35D8700B4856F /* ParraInstanceConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraInstanceConfiguration.swift; sourceTree = ""; }; + 00916DC92AD35D9600B4856F /* ParraInstanceStorageConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraInstanceStorageConfiguration.swift; sourceTree = ""; }; + 00916DCB2AD35D9D00B4856F /* ParraInstanceNetworkConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraInstanceNetworkConfiguration.swift; sourceTree = ""; }; 00A60B982932D35E00B7168A /* ParraConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraConfiguration.swift; sourceTree = ""; }; 00A60B9A2932D37000B7168A /* ParraAuthenticationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraAuthenticationProvider.swift; sourceTree = ""; }; - 00BD2FED2A4771D700B4BEB2 /* SyncState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncState.swift; sourceTree = ""; }; + 00B42EA62ADE176E00F365A5 /* Parra.xctestplan */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Parra.xctestplan; sourceTree = ""; }; + 00B42EAE2ADE1BCB00F365A5 /* TestRunner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TestRunner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 00B42EB02ADE1BCB00F365A5 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 00B42EB22ADE1BCB00F365A5 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 00B42EB72ADE1BCB00F365A5 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 00B42EB92ADE1BCC00F365A5 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 00B42EBC2ADE1BCC00F365A5 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 00B42EBE2ADE1BCC00F365A5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 00BC73152AC27BD300605873 /* Documentation.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = Documentation.docc; sourceTree = ""; }; + 00BD2FED2A4771D700B4BEB2 /* ParraSyncState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraSyncState.swift; sourceTree = ""; }; 00BD2FF12A4776E700B4BEB2 /* NetworkManagerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkManagerType.swift; sourceTree = ""; }; 00BD2FF32A47771300B4BEB2 /* AuthenticatedRequestAttributeOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatedRequestAttributeOptions.swift; sourceTree = ""; }; 00BD2FF52A47772A00B4BEB2 /* AuthenticatedRequestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatedRequestResult.swift; sourceTree = ""; }; @@ -349,11 +556,21 @@ 00BD2FFD2A479BBA00B4BEB2 /* ParraConfigState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraConfigState.swift; sourceTree = ""; }; 00BD2FFF2A47A70800B4BEB2 /* Parra+PushTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Parra+PushTests.swift"; sourceTree = ""; }; 00BDB2AE28BC24E700C16649 /* ParraCredentialTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraCredentialTests.swift; sourceTree = ""; }; - 00BDB2B028BD822700C16649 /* URLRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLRequest.swift; sourceTree = ""; }; + 00BDB2B028BD822700C16649 /* URLRequest+headers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLRequest+headers.swift"; sourceTree = ""; }; + 00BF36C62B59AB000097905C /* ParraFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraFixture.swift; sourceTree = ""; }; + 00BF36C82B59AB190097905C /* FeedbackFormSelectFieldData+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedbackFormSelectFieldData+Fixtures.swift"; sourceTree = ""; }; + 00BF36CA2B59B01D0097905C /* FeedbackFormField+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedbackFormField+Fixtures.swift"; sourceTree = ""; }; + 00BF36CC2B59BBDE0097905C /* FeedbackFormTextFieldData+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedbackFormTextFieldData+Fixtures.swift"; sourceTree = ""; }; + 00BF36D02B5AC3EE0097905C /* FormFieldWithState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormFieldWithState.swift; sourceTree = ""; }; + 00BF36D22B5ACA460097905C /* FeedbackFormViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackFormViewState.swift; sourceTree = ""; }; + 00BF36D42B5ACAD20097905C /* FormFieldState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormFieldState.swift; sourceTree = ""; }; + 00C00ABA2A8A6BC2003B21D3 /* SessionStorageContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionStorageContext.swift; sourceTree = ""; }; + 00C00ABC2A8A6BD1003B21D3 /* SessionReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionReader.swift; sourceTree = ""; }; 00CAD73329CFA1350093599E /* Int.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Int.swift; sourceTree = ""; }; 00CAD73529CFAE910093599E /* TextValidationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextValidationError.swift; sourceTree = ""; }; + 00D04C5E2AD429620024CA4E /* SessionReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionReaderTests.swift; sourceTree = ""; }; + 00D04C602AD437850024CA4E /* ParraBaseMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraBaseMock.swift; sourceTree = ""; }; 00D5155D27D4223D00C4F2CC /* Parra+Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Parra+Constants.swift"; sourceTree = ""; }; - 00D5156227D424C800C4F2CC /* ParraLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLogger.swift; sourceTree = ""; }; 00D5156427D4376500C4F2CC /* ParraSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraSyncManager.swift; sourceTree = ""; }; 00D5156627D4506B00C4F2CC /* Parra+SyncEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Parra+SyncEvents.swift"; sourceTree = ""; }; 00D5156827D45C8F00C4F2CC /* ParraNetworkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraNetworkManager.swift; sourceTree = ""; }; @@ -362,11 +579,9 @@ 00DA7FE7295CAB8F00109081 /* AnyCodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyCodable.swift; sourceTree = ""; }; 00DA7FE9295CAB9700109081 /* AnyEncodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyEncodable.swift; sourceTree = ""; }; 00DA7FEB295CAB9E00109081 /* AnyDecodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyDecodable.swift; sourceTree = ""; }; - 00DA7FEF295DD28700109081 /* Parra+Analytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Parra+Analytics.swift"; sourceTree = ""; }; + 00DA7FEF295DD28700109081 /* Parra+PublicAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Parra+PublicAnalytics.swift"; sourceTree = ""; }; 00DA7FF2295DD2D500109081 /* ParraSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraSession.swift; sourceTree = ""; }; - 00DA7FF4295DD2EC00109081 /* ParraSessionEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraSessionEvent.swift; sourceTree = ""; }; - 00DA7FF6295DD2FD00109081 /* ParraSessionEventType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraSessionEventType.swift; sourceTree = ""; }; - 00DA7FF8295F2A1600109081 /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = ""; }; + 00DA7FF8295F2A1600109081 /* URL+safePaths.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+safePaths.swift"; sourceTree = ""; }; 00DA7FFB296367D400109081 /* RequestConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestConfig.swift; sourceTree = ""; }; 00DA7FFD296367E400109081 /* ParraHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraHeader.swift; sourceTree = ""; }; 00DECFF5274C69F800DAF301 /* Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Demo.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -375,23 +590,46 @@ 00DED004274C69FA00DAF301 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 00DED006274C69FA00DAF301 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 00DEF7232A3FA77A006B0DF3 /* ParraCardModalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraCardModalViewController.swift; sourceTree = ""; }; - 00DEF7252A3FE659006B0DF3 /* UIDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIDevice.swift; sourceTree = ""; }; + 00DEF7252A3FE659006B0DF3 /* UIDevice+modelCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+modelCode.swift"; sourceTree = ""; }; 00E15A1E2929B7C10049C2C7 /* ParraSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraSessionManager.swift; sourceTree = ""; }; - 00E15A202929BC790049C2C7 /* UIDeviceOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIDeviceOrientation.swift; sourceTree = ""; }; + 00E15A202929BC790049C2C7 /* UIDeviceOrientation+ParraLogStringConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDeviceOrientation+ParraLogStringConvertible.swift"; sourceTree = ""; }; 00E15A22292AFB2B0049C2C7 /* SessionStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionStorage.swift; sourceTree = ""; }; 00E15A24293266470049C2C7 /* ParraCardsInModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraCardsInModal.swift; sourceTree = ""; }; 00E15A26293268600049C2C7 /* ParraFeedback+Modals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ParraFeedback+Modals.swift"; sourceTree = ""; }; 00E15A28293269100049C2C7 /* UIViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; - 00E15A2A29326FBE0049C2C7 /* UIWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIWindow.swift; sourceTree = ""; }; + 00E15A2A29326FBE0049C2C7 /* UIWindow+topViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIWindow+topViewController.swift"; sourceTree = ""; }; 00E15A2C293276940049C2C7 /* Thread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thread.swift; sourceTree = ""; }; 00E15A30293279A20049C2C7 /* ParraCardPopupViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraCardPopupViewController.swift; sourceTree = ""; }; 00E15A32293279B80049C2C7 /* ParraCardModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraCardModal.swift; sourceTree = ""; }; 00E15A342932971C0049C2C7 /* ParraCardDrawerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraCardDrawerViewController.swift; sourceTree = ""; }; - 00E7957B29A05CBD003FE68D /* ParraLoggerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLoggerConfig.swift; sourceTree = ""; }; + 00E269E32A523000001A0F50 /* ParraFeedbackPopupState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraFeedbackPopupState.swift; sourceTree = ""; }; + 00E269E72A523CD5001A0F50 /* ParraConfigurationOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraConfigurationOption.swift; sourceTree = ""; }; + 00E269E92A524842001A0F50 /* ParraModuleStateAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraModuleStateAccessor.swift; sourceTree = ""; }; + 00E269EB2A5257C0001A0F50 /* URLRequestHeaderField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLRequestHeaderField.swift; sourceTree = ""; }; + 00E269ED2A5257DC001A0F50 /* AuthorizationType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationType.swift; sourceTree = ""; }; + 00E269EF2A525803001A0F50 /* Mimetype.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mimetype.swift; sourceTree = ""; }; + 00E269F42A526770001A0F50 /* Endpoint+Mocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Endpoint+Mocks.swift"; sourceTree = ""; }; + 00E269F72A530771001A0F50 /* XCTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCTestCase.swift; sourceTree = ""; }; + 00E269F92A530D69001A0F50 /* ParraEndpoint+CaseIterable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ParraEndpoint+CaseIterable.swift"; sourceTree = ""; }; + 00E269FD2A5310D5001A0F50 /* URLSessionDataTaskType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionDataTaskType.swift; sourceTree = ""; }; + 00E269FF2A53110F001A0F50 /* MockURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLSession.swift; sourceTree = ""; }; + 00E26A022A53120F001A0F50 /* TestData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestData.swift; sourceTree = ""; }; + 00E26A042A53132E001A0F50 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; + 00E26A062A534334001A0F50 /* ParraState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraState.swift; sourceTree = ""; }; 00E7957D29A05CD6003FE68D /* ParraLogLevel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLogLevel.swift; sourceTree = ""; }; - 00E7957F29A12E29003FE68D /* ParraDefaultLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraDefaultLogger.swift; sourceTree = ""; }; 00E8B32B278730D500FC3FFD /* ParraCardAnswerHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraCardAnswerHandler.swift; sourceTree = ""; }; 00E8B32D27878C3400FC3FFD /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + 00EAE2F42A9BC3DB003FB41C /* FileHandleType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileHandleType.swift; sourceTree = ""; }; + 00EAE2F62A9BE71F003FB41C /* URL+helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+helpers.swift"; sourceTree = ""; }; + 00EAE2F82A9F7D42003FB41C /* ParraSessionEventTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraSessionEventTarget.swift; sourceTree = ""; }; + 00EAE2FA2AA0C196003FB41C /* ParraSessionGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraSessionGenerator.swift; sourceTree = ""; }; + 00EAE2FD2AA0C8A2003FB41C /* ParraSessionGeneratorType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraSessionGeneratorType.swift; sourceTree = ""; }; + 00EBCE072AA4C1E300887639 /* ParraLogContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLogContext.swift; sourceTree = ""; }; + 00EBCE092AA4C1EE00887639 /* ParraLoggerScopeType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLoggerScopeType.swift; sourceTree = ""; }; + 00EBCE0C2AA4CEEF00887639 /* ParraLoggerStackSymbols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLoggerStackSymbols.swift; sourceTree = ""; }; + 00EBCE0E2AA4CF0C00887639 /* QualityOfService+Codable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QualityOfService+Codable.swift"; sourceTree = ""; }; + 00EBCE132AA4D58F00887639 /* ParraLogLevel+ConsoleOutput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ParraLogLevel+ConsoleOutput.swift"; sourceTree = ""; }; + 00EBCE152AA4F0D100887639 /* ParraLoggerExtraStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraLoggerExtraStyle.swift; sourceTree = ""; }; 00F5AAF027FA819200A94C85 /* ParraCardsInTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraCardsInTableView.swift; sourceTree = ""; }; 00F5AAF328023EF300A94C85 /* GeneratedTypes+Swift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GeneratedTypes+Swift.swift"; sourceTree = ""; }; 00FD310A28329F5B0080689C /* ParraCardCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParraCardCollectionViewCell.swift; sourceTree = ""; }; @@ -414,6 +652,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 00B42EAB2ADE1BCB00F365A5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 00719DB82AF69CAA00310E25 /* Parra.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 00DECFF2274C69F800DAF301 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -438,24 +684,26 @@ 00120EF1274D1ECA00CA3247 /* Extensions */ = { isa = PBXGroup; children = ( - 00901F2C2991E3430071647F /* Encodable.swift */, - 0036D60B27D053160016DE97 /* FileManager.swift */, + 0082DFCD2A53BA6700E7A91D /* Date+now.swift */, + 0036D60B27D053160016DE97 /* FileManager+safePaths.swift */, + 0066540D2A7EBAAF00CD04E6 /* HTTPURLResponse+ParraDictionaryConvertible.swift */, 0036D5E527CB0FBD0016DE97 /* JSONDecoder.swift */, 0036D5E327CB0EEA0016DE97 /* JSONEncoder.swift */, 00E8B32D27878C3400FC3FFD /* Models.swift */, - 0019CBC828FB37BC0015E703 /* Sequence.swift */, - 00E15A2C293276940049C2C7 /* Thread.swift */, - 008878112979A3E20087AC1D /* UIApplication.swift */, - 00DEF7252A3FE659006B0DF3 /* UIDevice.swift */, - 00E15A202929BC790049C2C7 /* UIDeviceOrientation.swift */, - 001286FE277FE3180090CDB5 /* UIFont.swift */, + 0019CBC828FB37BC0015E703 /* Sequence+asyncMap.swift */, + 001286FE277FE3180090CDB5 /* UIFont+registration.swift */, 00E15A28293269100049C2C7 /* UIViewController.swift */, - 00E15A2A29326FBE0049C2C7 /* UIWindow.swift */, - 00DA7FF8295F2A1600109081 /* URL.swift */, - 0018A60D28D77FF2008CC97E /* URLComponents.swift */, - 00BDB2B028BD822700C16649 /* URLRequest.swift */, + 00E15A2A29326FBE0049C2C7 /* UIWindow+topViewController.swift */, + 00DA7FF8295F2A1600109081 /* URL+safePaths.swift */, + 00EAE2F62A9BE71F003FB41C /* URL+helpers.swift */, + 0018A60D28D77FF2008CC97E /* URLComponents+queryItems.swift */, + 00BDB2B028BD822700C16649 /* URLRequest+headers.swift */, + 006654112A80573400CD04E6 /* URLRequest.Attribution+CustomStringConvertible.swift */, + 006654092A7EAAFA00CD04E6 /* URLRequest+ParraDictionaryConvertible.swift */, + 0066540F2A7EBDB300CD04E6 /* NSURLRequest.CachePolicy+CustomStringConvertible.swift */, 00707D1A27FA0A07004E9567 /* URLSession.swift */, - 00731EFA2A182639004010A3 /* Task.swift */, + 00731EFA2A182639004010A3 /* Task+sleep.swift */, + 00214C6D2AB62510001C5313 /* String+prefixes.swift */, ); path = Extensions; sourceTree = ""; @@ -493,11 +741,9 @@ 001286F7277F50A20090CDB5 /* Managers */ = { isa = PBXGroup; children = ( - 00D5156827D45C8F00C4F2CC /* ParraNetworkManager.swift */, - 001B7B7F27DE60F600DEEECD /* ParraDataManager.swift */, - 001B7B8127DE618300DEEECD /* ParraDataManager+Keys.swift */, + 0029AD242A48DA2200E30CCD /* Data */, + 0029AD252A48DA2D00E30CCD /* Network */, 00DA7FE3295C95AF00109081 /* Sync */, - 00A60B9C2933929900B7168A /* Sessions */, ); path = Managers; sourceTree = ""; @@ -506,9 +752,9 @@ isa = PBXGroup; children = ( 001559B427E7EAD9006450BE /* ParraNetworkManagerTests.swift */, - 00707D1627F884BB004E9567 /* ParraNetworkManagerTests+Mocks.swift */, 001559B227E7EACC006450BE /* ParraSyncManagerTests.swift */, 001559B027E7EAC0006450BE /* ParraDataManagerTests.swift */, + 0082DFCB2A53B6F100E7A91D /* ParraSessionManagerTests.swift */, ); path = Managers; sourceTree = ""; @@ -535,7 +781,13 @@ 0015599D27E40B64006450BE /* Extensions */ = { isa = PBXGroup; children = ( + 0082DFBC2A535FDD00E7A91D /* Data.swift */, 0015599E27E40B71006450BE /* FileManagerTests.swift */, + 0082DFC82A538A5400E7A91D /* ParraAuthenticationProviderType.swift */, + 00E269F92A530D69001A0F50 /* ParraEndpoint+CaseIterable.swift */, + 00E26A062A534334001A0F50 /* ParraState.swift */, + 00E26A042A53132E001A0F50 /* String.swift */, + 00E269F72A530771001A0F50 /* XCTestCase.swift */, ); path = Extensions; sourceTree = ""; @@ -544,7 +796,6 @@ isa = PBXGroup; children = ( 001559A127E40D56006450BE /* PersistentStorageTestHelpers.swift */, - 005E38A427FA1E0200F32F96 /* NetworkTestHelpers.swift */, ); path = TestHelpers; sourceTree = ""; @@ -556,14 +807,15 @@ 001B7B6E27DE3CAB00DEEECD /* Card Completion */, 00A60B972932D34F00B7168A /* Configuration */, 00DA7FFA296367A100109081 /* Network */, - 00DA7FF1295DD2BE00109081 /* Sessions */, + 0019CBC428FAE0E80015E703 /* FailableDecodable.swift */, 007FBEF3277A34C700BD9E37 /* GeneratedTypes.swift */, 00120F002753E9A400CA3247 /* ParraCredential.swift */, + 0036D5E127CACA100016DE97 /* ParraError.swift */, + 0066540B2A7EAEF000CD04E6 /* ParraErrorWithExtra.swift */, 0019CBC228F639040015E703 /* ParraQuestionAppArea.swift */, - 0019CBC428FAE0E80015E703 /* FailableDecodable.swift */, 00BD2FFB2A477D7700B4BEB2 /* ParraNotificationCenter.swift */, - 00BD2FF92A47778E00B4BEB2 /* NotificationCenterType.swift */, 00497D7829F88CC4004EF5AB /* ParraSessionsResponse.swift */, + 00BD2FF92A47778E00B4BEB2 /* NotificationCenterType.swift */, ); path = Types; sourceTree = ""; @@ -585,7 +837,8 @@ 001B7A9A27DE34F200DEEECD /* Supporting Files */ = { isa = PBXGroup; children = ( - 001286FC277FE2320090CDB5 /* Pacifico-Regular.ttf */, + 001B7AF427DE3A7600DEEECD /* Parra.h */, + 003621DC2B5B592D009EFE53 /* ParraAssets.xcassets */, ); path = "Supporting Files"; sourceTree = ""; @@ -593,26 +846,17 @@ 001B7AF327DE3A7600DEEECD /* Parra */ = { isa = PBXGroup; children = ( - 001559AD27E7E2F3006450BE /* Parra.xctestplan */, - 001B7AF427DE3A7600DEEECD /* Parra.h */, - 003D2A74274C77BD001E067E /* Parra.swift */, - 001B7A8D27DD8B5500DEEECD /* ParraModule.swift */, - 00DA7FEF295DD28700109081 /* Parra+Analytics.swift */, - 0036D60127CF0EA20016DE97 /* Parra+Authentication.swift */, - 00D5155D27D4223D00C4F2CC /* Parra+Constants.swift */, - 001B7A9127DD8DCE00DEEECD /* Parra+Endpoints.swift */, - 005E38A627FA3A8700F32F96 /* Parra+Notifications.swift */, - 006DE97F2A472F1B00521F5D /* Parra+Push.swift */, - 00D5156627D4506B00C4F2CC /* Parra+SyncEvents.swift */, - 0036D5E127CACA100016DE97 /* ParraError.swift */, + 0087CAA92A59E927007D72FA /* Core */, 00120EF1274D1ECA00CA3247 /* Extensions */, 003962322A48B4AC00B8655F /* Feedback */, - 00E7957A29A05CB0003FE68D /* Logger */, 001286F7277F50A20090CDB5 /* Managers */, 0036D5EF27CD9BC90016DE97 /* PersistentStorage */, + 0087CA9D2A59E4DE007D72FA /* Sessions */, 006DE9842A4738F000521F5D /* State */, 001B7A9A27DE34F200DEEECD /* Supporting Files */, 001B7A8A27DD881300DEEECD /* Types */, + 003621D12B5B55F2009EFE53 /* Views */, + 00BC73152AC27BD300605873 /* Documentation.docc */, ); path = Parra; sourceTree = ""; @@ -620,15 +864,20 @@ 001B7AFF27DE3A7600DEEECD /* ParraTests */ = { isa = PBXGroup; children = ( + 00B42EA62ADE176E00F365A5 /* Parra.xctestplan */, 001B7B0027DE3A7600DEEECD /* ParraTests.swift */, 00320A5A27ED4EF0001EB323 /* Parra+AuthenticationTests.swift */, 00BD2FFF2A47A70800B4BEB2 /* Parra+PushTests.swift */, 001559A727E417D4006450BE /* ParraLoggerTests.swift */, + 00E26A012A5311F8001A0F50 /* Data */, 0015599D27E40B64006450BE /* Extensions */, 0015599827E40AC7006450BE /* Managers */, + 0082DFBE2A53733600E7A91D /* Mocks */, 0015599927E40ADD006450BE /* PersistentStorage */, 001559A027E40D49006450BE /* TestHelpers */, 00F5AAF228023EDB00A94C85 /* Types */, + 0029AD2C2A48E0EE00E30CCD /* Util */, + 00916DC12AD2EFCD00B4856F /* SignpostTestObserver.swift */, ); path = ParraTests; sourceTree = ""; @@ -640,6 +889,7 @@ 001B7B6727DE3C1200DEEECD /* VisibleButtonOptions.swift */, 00CAD73529CFAE910093599E /* TextValidationError.swift */, 001B7B6927DE3C2400DEEECD /* CurrentCardInfo.swift */, + 00E269E32A523000001A0F50 /* ParraFeedbackPopupState.swift */, 000AF4C1283AE6D8008A17A3 /* Config */, ); path = Types; @@ -654,15 +904,91 @@ path = "Card Completion"; sourceTree = ""; }; + 0029AD242A48DA2200E30CCD /* Data */ = { + isa = PBXGroup; + children = ( + 001B7B7F27DE60F600DEEECD /* ParraDataManager.swift */, + 001B7B8127DE618300DEEECD /* ParraDataManager+Keys.swift */, + ); + path = Data; + sourceTree = ""; + }; + 0029AD252A48DA2D00E30CCD /* Network */ = { + isa = PBXGroup; + children = ( + 00D5156827D45C8F00C4F2CC /* ParraNetworkManager.swift */, + 001B7A9127DD8DCE00DEEECD /* ParraNetworkManager+Endpoints.swift */, + 0087CA522A5466CF007D72FA /* ParraNetworkManager+ParraUrlSessionDelegate.swift */, + ); + path = Network; + sourceTree = ""; + }; + 0029AD262A48DBB700E30CCD /* Analytics */ = { + isa = PBXGroup; + children = ( + 0029AD2F2A490CDB00E30CCD /* Parra+InternalAnalytics.swift */, + 00DA7FEF295DD28700109081 /* Parra+PublicAnalytics.swift */, + ); + path = Analytics; + sourceTree = ""; + }; + 0029AD2C2A48E0EE00E30CCD /* Util */ = { + isa = PBXGroup; + children = ( + 0029AD2D2A48E11300E30CCD /* LoggerHelpersTests.swift */, + ); + path = Util; + sourceTree = ""; + }; + 0029AD312A49253D00E30CCD /* Events */ = { + isa = PBXGroup; + children = ( + 0029AD322A49256300E30CCD /* ParraEvent.swift */, + 0029AD382A4925D000E30CCD /* ParraInternalEvent.swift */, + 007868982AAE4DE300864CFE /* ParraLogEvent.swift */, + 0029AD362A4925C000E30CCD /* ParraStandardEvent.swift */, + 007ED4712A6C2C810077E446 /* ParraWrappedEvent.swift */, + 0078689E2AAE550E00864CFE /* ParraSessionEvent */, + ); + path = Events; + sourceTree = ""; + }; + 003621D12B5B55F2009EFE53 /* Views */ = { + isa = PBXGroup; + children = ( + 003621D82B5B565A009EFE53 /* Fixtures */, + 003621D92B5B5694009EFE53 /* Logo */, + ); + path = Views; + sourceTree = ""; + }; + 003621D82B5B565A009EFE53 /* Fixtures */ = { + isa = PBXGroup; + children = ( + 00BF36C62B59AB000097905C /* ParraFixture.swift */, + ); + path = Fixtures; + sourceTree = ""; + }; + 003621D92B5B5694009EFE53 /* Logo */ = { + isa = PBXGroup; + children = ( + 003621D62B5B563C009EFE53 /* ParraLogo.swift */, + 003621DA2B5B569F009EFE53 /* ParraLogoType.swift */, + 003621E82B5C2784009EFE53 /* ParraLogoButton.swift */, + ); + path = Logo; + sourceTree = ""; + }; 0036D5EF27CD9BC90016DE97 /* PersistentStorage */ = { isa = PBXGroup; children = ( - 001B7B8727DE712200DEEECD /* ParraStorageModule.swift */, - 003C214627DE855A001CAE03 /* ItemStorage.swift */, 0036D5F027CD9BDA0016DE97 /* CredentialStorage.swift */, - 00E15A22292AFB2B0049C2C7 /* SessionStorage.swift */, 001B7B8D27DE806100DEEECD /* DataStorageMedium.swift */, + 003C214627DE855A001CAE03 /* ItemStorage.swift */, + 001B7B8727DE712200DEEECD /* ParraStorageModule.swift */, 0036D5FA27CEFA540016DE97 /* PersistentStorageMedium */, + 006654372A89AFA200CD04E6 /* Sessions */, ); path = PersistentStorage; sourceTree = ""; @@ -680,6 +1006,7 @@ 003962322A48B4AC00B8655F /* Feedback */ = { isa = PBXGroup; children = ( + 00BF36C52B59AAB60097905C /* Fixtures */, 001B7A8F27DD8D6700DEEECD /* ParraFeedback.swift */, 00E15A26293268600049C2C7 /* ParraFeedback+Modals.swift */, 001B7B8327DE6E2400DEEECD /* ParraFeedback+Constants.swift */, @@ -717,6 +1044,14 @@ path = Views; sourceTree = ""; }; + 0040DEAB2AA22661007E2190 /* Public */ = { + isa = PBXGroup; + children = ( + 0040DEAC2AA226A3007E2190 /* UIViewController+defaultLogger.swift */, + ); + path = Public; + sourceTree = ""; + }; 005D31FB29B39960001D0E0A /* Utils */ = { isa = PBXGroup; children = ( @@ -736,16 +1071,182 @@ path = Fields; sourceTree = ""; }; + 006654372A89AFA200CD04E6 /* Sessions */ = { + isa = PBXGroup; + children = ( + 00C00ABC2A8A6BD1003B21D3 /* SessionReader.swift */, + 00D04C5E2AD429620024CA4E /* SessionReaderTests.swift */, + 00E15A22292AFB2B0049C2C7 /* SessionStorage.swift */, + 00C00ABA2A8A6BC2003B21D3 /* SessionStorageContext.swift */, + 00EAE2FC2AA0C888003FB41C /* Generators */, + 00EAE2F42A9BC3DB003FB41C /* FileHandleType.swift */, + ); + path = Sessions; + sourceTree = ""; + }; 006DE9842A4738F000521F5D /* State */ = { isa = PBXGroup; children = ( - 006DE9852A47390600521F5D /* ParraGlobalState.swift */, - 00BD2FED2A4771D700B4BEB2 /* SyncState.swift */, 00BD2FFD2A479BBA00B4BEB2 /* ParraConfigState.swift */, + 00E269E92A524842001A0F50 /* ParraModuleStateAccessor.swift */, + 00BD2FED2A4771D700B4BEB2 /* ParraSyncState.swift */, + 006DE9852A47390600521F5D /* ParraState.swift */, ); path = State; sourceTree = ""; }; + 0078689E2AAE550E00864CFE /* ParraSessionEvent */ = { + isa = PBXGroup; + children = ( + 007ED4732A6C458A0077E446 /* ParraSessionEvent.swift */, + 007868A22AAE84CA00864CFE /* ParraSessionEventContext.swift */, + 0078689C2AAE54F800864CFE /* ParraSessionEventSyncPriority.swift */, + ); + path = ParraSessionEvent; + sourceTree = ""; + }; + 007ED47E2A6C9D2A0077E446 /* CallStackParser */ = { + isa = PBXGroup; + children = ( + 007ED47F2A6C9D370077E446 /* CallStackFrame.swift */, + 007ED4752A6C8BDF0077E446 /* CallStackParser.swift */, + ); + path = CallStackParser; + sourceTree = ""; + }; + 0082DFBE2A53733600E7A91D /* Mocks */ = { + isa = PBXGroup; + children = ( + 0082DFC52A5374BD00E7A91D /* MockParra.swift */, + 0082DFBF2A53733D00E7A91D /* Network */, + 00916DBF2ACCEE7600B4856F /* MockedParraTestCase.swift */, + 00D04C602AD437850024CA4E /* ParraBaseMock.swift */, + ); + path = Mocks; + sourceTree = ""; + }; + 0082DFBF2A53733D00E7A91D /* Network */ = { + isa = PBXGroup; + children = ( + 00E269FF2A53110F001A0F50 /* MockURLSession.swift */, + 00E269FD2A5310D5001A0F50 /* URLSessionDataTaskType.swift */, + 0082DFC02A53736F00E7A91D /* MockParraNetworkManager.swift */, + ); + path = Network; + sourceTree = ""; + }; + 0087CA6A2A57B2DB007D72FA /* Types */ = { + isa = PBXGroup; + children = ( + 0087CA882A59B3EC007D72FA /* Options */, + 0087CA6F2A57B331007D72FA /* ParraLoggerBackend.swift */, + 00EBCE062AA4C1D400887639 /* Context */, + 007ED4662A6BFBDC0077E446 /* ParraLoggerContext+ParraDictionaryConvertible.swift */, + 0087CA912A59BA51007D72FA /* ParraLoggerEnvironment.swift */, + 00EBCE0B2AA4CEDE00887639 /* Threads */, + 0087CAAA2A59F3B4007D72FA /* ParraLogData.swift */, + 007ED46B2A6BFBF50077E446 /* ParraLogProcessedData.swift */, + 007ED46C2A6BFBF50077E446 /* ParraLogProcessedData+ParraDictionaryConvertible.swift */, + 0087CA822A599FDD007D72FA /* ParraLogStringConvertible.swift */, + 00EBCE122AA4D57100887639 /* Level */, + 0087CA6D2A57B308007D72FA /* ParraLogMarker.swift */, + 0087CAAC2A5A2B1A007D72FA /* ParraLogMarkerMeasurement.swift */, + 0087CA732A57B38E007D72FA /* ParraLogMeasurementFormat.swift */, + ); + path = Types; + sourceTree = ""; + }; + 0087CA812A599FC4007D72FA /* Extensions */ = { + isa = PBXGroup; + children = ( + 0040DEAB2AA22661007E2190 /* Public */, + 0087CA992A59DFCC007D72FA /* ProcessInfoPowerState+ParraLogStringConvertible.swift */, + 0087CA972A59DEC8007D72FA /* ProcessInfoThermalState+ParraLogStringConvertible.swift */, + 008878112979A3E20087AC1D /* UIApplicationState+ParraLogStringConvertible.swift */, + 00E15A202929BC790049C2C7 /* UIDeviceOrientation+ParraLogStringConvertible.swift */, + 0087CA952A59D82D007D72FA /* UIDeviceBatteryState+ParraLogStringConvertible.swift */, + 0087CA842A59A046007D72FA /* QualityOfService+ParraLogDescription.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 0087CA882A59B3EC007D72FA /* Options */ = { + isa = PBXGroup; + children = ( + 0087CA8D2A59B41C007D72FA /* ParraLoggerCallSiteStyleOptions.swift */, + 00EBCE152AA4F0D100887639 /* ParraLoggerExtraStyle.swift */, + 0087CA932A59C0FB007D72FA /* ParraLoggerConsoleFormatOption.swift */, + 0087CA8B2A59B407007D72FA /* ParraLoggerLevelStyle.swift */, + 0087CA8F2A59B435007D72FA /* ParraLoggerOptions.swift */, + 0087CA892A59B3F9007D72FA /* ParraLoggerTimestampStyle.swift */, + ); + path = Options; + sourceTree = ""; + }; + 0087CA9D2A59E4DE007D72FA /* Sessions */ = { + isa = PBXGroup; + children = ( + 0087CAA82A59E80A007D72FA /* Extensions */, + 0029AD262A48DBB700E30CCD /* Analytics */, + 00E7957A29A05CB0003FE68D /* Logger */, + 0087CAA72A59E7CE007D72FA /* Manager */, + 0087CA9E2A59E4E8007D72FA /* Types */, + ); + path = Sessions; + sourceTree = ""; + }; + 0087CA9E2A59E4E8007D72FA /* Types */ = { + isa = PBXGroup; + children = ( + 0029AD312A49253D00E30CCD /* Events */, + 0087CAA12A59E507007D72FA /* ParraDiskUsage.swift */, + 00DA7FF2295DD2D500109081 /* ParraSession.swift */, + 006654132A8066BC00CD04E6 /* ParraSessionUpload.swift */, + 0087CA9F2A59E4F1007D72FA /* ParraSanitizedDictionaryConvertible.swift */, + 00214C692AB3E2BC001C5313 /* ParraSanitizedDictionary.swift */, + 00214C6B2AB3E3A0001C5313 /* ParraDataSanitizer.swift */, + 0029AD3A2A49290A00E30CCD /* StringManipulators.swift */, + ); + path = Types; + sourceTree = ""; + }; + 0087CAA72A59E7CE007D72FA /* Manager */ = { + isa = PBXGroup; + children = ( + 00E15A1E2929B7C10049C2C7 /* ParraSessionManager.swift */, + 0087CA7B2A599916007D72FA /* ParraSessionManager+LogFormatters.swift */, + 0087CA7D2A5999D7007D72FA /* ParraSessionManager+LogProcessors.swift */, + 00EAE2F82A9F7D42003FB41C /* ParraSessionEventTarget.swift */, + ); + path = Manager; + sourceTree = ""; + }; + 0087CAA82A59E80A007D72FA /* Extensions */ = { + isa = PBXGroup; + children = ( + 0087CAA32A59E51B007D72FA /* ParraDiskUsage+ParraSessionParamDictionaryConvertible.swift */, + 00E15A2C293276940049C2C7 /* Thread.swift */, + 00DEF7252A3FE659006B0DF3 /* UIDevice+modelCode.swift */, + 0087CA9B2A59E166007D72FA /* URL+diskUsage.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 0087CAA92A59E927007D72FA /* Core */ = { + isa = PBXGroup; + children = ( + 003D2A74274C77BD001E067E /* Parra.swift */, + 001B7A8D27DD8B5500DEEECD /* ParraModule.swift */, + 0036D60127CF0EA20016DE97 /* Parra+Authentication.swift */, + 00D5155D27D4223D00C4F2CC /* Parra+Constants.swift */, + 005E38A627FA3A8700F32F96 /* Parra+Notifications.swift */, + 006DE97F2A472F1B00521F5D /* Parra+Push.swift */, + 00D5156627D4506B00C4F2CC /* Parra+SyncEvents.swift */, + 00916DC62AD35D7300B4856F /* Instance Config */, + ); + path = Core; + sourceTree = ""; + }; 00901F322999AC990071647F /* Question Kind Cards */ = { isa = PBXGroup; children = ( @@ -762,21 +1263,70 @@ path = "Question Kind Cards"; sourceTree = ""; }; + 00916DC62AD35D7300B4856F /* Instance Config */ = { + isa = PBXGroup; + children = ( + 00916DC72AD35D8700B4856F /* ParraInstanceConfiguration.swift */, + 00916DCB2AD35D9D00B4856F /* ParraInstanceNetworkConfiguration.swift */, + 00916DC92AD35D9600B4856F /* ParraInstanceStorageConfiguration.swift */, + ); + path = "Instance Config"; + sourceTree = ""; + }; 00A60B972932D34F00B7168A /* Configuration */ = { isa = PBXGroup; children = ( 00A60B982932D35E00B7168A /* ParraConfiguration.swift */, + 00E269E72A523CD5001A0F50 /* ParraConfigurationOption.swift */, 00A60B9A2932D37000B7168A /* ParraAuthenticationProvider.swift */, ); path = Configuration; sourceTree = ""; }; - 00A60B9C2933929900B7168A /* Sessions */ = { + 00B42EAF2ADE1BCB00F365A5 /* TestRunner */ = { isa = PBXGroup; children = ( - 00E15A1E2929B7C10049C2C7 /* ParraSessionManager.swift */, + 00719DB72AF69B9E00310E25 /* TestRunner.entitlements */, + 00B42EB02ADE1BCB00F365A5 /* AppDelegate.swift */, + 00B42EB22ADE1BCB00F365A5 /* SceneDelegate.swift */, + 00B42EB62ADE1BCB00F365A5 /* Main.storyboard */, + 00B42EB92ADE1BCC00F365A5 /* Assets.xcassets */, + 00B42EBB2ADE1BCC00F365A5 /* LaunchScreen.storyboard */, + 00B42EBE2ADE1BCC00F365A5 /* Info.plist */, + ); + path = TestRunner; + sourceTree = ""; + }; + 00BF36C52B59AAB60097905C /* Fixtures */ = { + isa = PBXGroup; + children = ( + 00BF36CA2B59B01D0097905C /* FeedbackFormField+Fixtures.swift */, + 00BF36C82B59AB190097905C /* FeedbackFormSelectFieldData+Fixtures.swift */, + 00BF36CC2B59BBDE0097905C /* FeedbackFormTextFieldData+Fixtures.swift */, + 003621EA2B5C2A62009EFE53 /* FeedbackFormViewState+Fixtures.swift */, ); - path = Sessions; + path = Fixtures; + sourceTree = ""; + }; + 00BF36CE2B5AC3CA0097905C /* Feedback Forms */ = { + isa = PBXGroup; + children = ( + 003E3F8B2A2E3B25007373C2 /* ParraFeedbackFormViewController.swift */, + 006153E12A2FCCD600ED4CBF /* ParraFeedbackFormView.swift */, + 00BF36CF2B5AC3E80097905C /* State */, + 006153EB2A36822200ED4CBF /* Fields */, + ); + path = "Feedback Forms"; + sourceTree = ""; + }; + 00BF36CF2B5AC3E80097905C /* State */ = { + isa = PBXGroup; + children = ( + 00BF36D22B5ACA460097905C /* FeedbackFormViewState.swift */, + 00BF36D02B5AC3EE0097905C /* FormFieldWithState.swift */, + 00BF36D42B5ACAD20097905C /* FormFieldState.swift */, + ); + path = State; sourceTree = ""; }; 00DA7FE3295C95AF00109081 /* Sync */ = { @@ -798,27 +1348,24 @@ path = AnyCodable; sourceTree = ""; }; - 00DA7FF1295DD2BE00109081 /* Sessions */ = { - isa = PBXGroup; - children = ( - 00DA7FF2295DD2D500109081 /* ParraSession.swift */, - 00DA7FF4295DD2EC00109081 /* ParraSessionEvent.swift */, - 00DA7FF6295DD2FD00109081 /* ParraSessionEventType.swift */, - ); - path = Sessions; - sourceTree = ""; - }; 00DA7FFA296367A100109081 /* Network */ = { isa = PBXGroup; children = ( 00BD2FF32A47771300B4BEB2 /* AuthenticatedRequestAttributeOptions.swift */, 00BD2FF52A47772A00B4BEB2 /* AuthenticatedRequestResult.swift */, + 00E269ED2A5257DC001A0F50 /* AuthorizationType.swift */, 006DE9812A472F9C00521F5D /* Endpoint.swift */, + 00E269F42A526770001A0F50 /* Endpoint+Mocks.swift */, 001B7A8B27DD883500DEEECD /* HttpMethod.swift */, + 00E269EF2A525803001A0F50 /* Mimetype.swift */, 00BD2FF12A4776E700B4BEB2 /* NetworkManagerType.swift */, 00DA7FFD296367E400109081 /* ParraHeader.swift */, + 0087CA502A5466A6007D72FA /* ParraNetworkManagerUrlSessionDelegateProxy.swift */, + 0087CA4E2A54668B007D72FA /* ParraUrlSessionDelegate.swift */, 00DA7FFB296367D400109081 /* RequestConfig.swift */, + 00E269EB2A5257C0001A0F50 /* URLRequestHeaderField.swift */, 00BD2FF72A47774300B4BEB2 /* URLSessionType.swift */, + 0087CA542A54670A007D72FA /* EmptyJsonObjects.swift */, ); path = Network; sourceTree = ""; @@ -830,6 +1377,7 @@ 00DECFF7274C69F800DAF301 /* Demo */, 001B7AF327DE3A7600DEEECD /* Parra */, 001B7AFF27DE3A7600DEEECD /* ParraTests */, + 00B42EAF2ADE1BCB00F365A5 /* TestRunner */, 00DECFF6274C69F800DAF301 /* Products */, 02A060188E5CD1963038AE8C /* Pods */, 39DADA99F3B351D04A5CD522 /* Frameworks */, @@ -842,6 +1390,7 @@ 00DECFF5274C69F800DAF301 /* Demo.app */, 001B7AF227DE3A7600DEEECD /* Parra.framework */, 001B7AF927DE3A7600DEEECD /* ParraTests.xctest */, + 00B42EAE2ADE1BCB00F365A5 /* TestRunner.app */, ); name = Products; sourceTree = ""; @@ -885,25 +1434,78 @@ 00DEF7232A3FA77A006B0DF3 /* ParraCardModalViewController.swift */, 00E15A30293279A20049C2C7 /* ParraCardPopupViewController.swift */, 00E15A342932971C0049C2C7 /* ParraCardDrawerViewController.swift */, - 003E3F8B2A2E3B25007373C2 /* ParraFeedbackFormViewController.swift */, - 006153E12A2FCCD600ED4CBF /* ParraFeedbackFormView.swift */, - 006153EB2A36822200ED4CBF /* Fields */, + 00BF36CE2B5AC3CA0097905C /* Feedback Forms */, ); path = Modals; sourceTree = ""; }; + 00E26A012A5311F8001A0F50 /* Data */ = { + isa = PBXGroup; + children = ( + 00E26A022A53120F001A0F50 /* TestData.swift */, + ); + path = Data; + sourceTree = ""; + }; 00E7957A29A05CB0003FE68D /* Logger */ = { isa = PBXGroup; children = ( - 00D5156227D424C800C4F2CC /* ParraLogger.swift */, - 00E7957B29A05CBD003FE68D /* ParraLoggerConfig.swift */, - 00E7957D29A05CD6003FE68D /* ParraLogLevel.swift */, - 00E7957F29A12E29003FE68D /* ParraDefaultLogger.swift */, - 006DE9872A47547900521F5D /* ParraWrappedLogMessage.swift */, + 0087CA812A599FC4007D72FA /* Extensions */, + 0087CA6A2A57B2DB007D72FA /* Types */, + 0087CA652A56114C007D72FA /* Logger.swift */, + 007ED46F2A6BFC020077E446 /* Logger+scope.swift */, + 0087CA752A57B3C0007D72FA /* Logger+Timers.swift */, + 0087CA672A561F72007D72FA /* Logger+Levels.swift */, + 0087CA792A58FAA2007D72FA /* Logger+StaticLevels.swift */, + 006DE9872A47547900521F5D /* ParraLazyLogParam.swift */, + 0066543A2A8A5F0E00CD04E6 /* LoggerFormatters.swift */, + 0029AD2A2A48DCE100E30CCD /* LoggerHelpers.swift */, + 007ED47E2A6C9D2A0077E446 /* CallStackParser */, ); path = Logger; sourceTree = ""; }; + 00EAE2FC2AA0C888003FB41C /* Generators */ = { + isa = PBXGroup; + children = ( + 00EAE2FA2AA0C196003FB41C /* ParraSessionGenerator.swift */, + 006654382A89AFB000CD04E6 /* ParraSessionUploadGenerator.swift */, + 00EAE2FD2AA0C8A2003FB41C /* ParraSessionGeneratorType.swift */, + ); + path = Generators; + sourceTree = ""; + }; + 00EBCE062AA4C1D400887639 /* Context */ = { + isa = PBXGroup; + children = ( + 00EBCE072AA4C1E300887639 /* ParraLogContext.swift */, + 0087CA712A57B343007D72FA /* ParraLoggerCallSiteContext.swift */, + 007ED4652A6BFBDC0077E446 /* ParraLoggerContext.swift */, + 00EBCE092AA4C1EE00887639 /* ParraLoggerScopeType.swift */, + ); + path = Context; + sourceTree = ""; + }; + 00EBCE0B2AA4CEDE00887639 /* Threads */ = { + isa = PBXGroup; + children = ( + 0087CA862A59A0D5007D72FA /* ParraLoggerThreadInfo.swift */, + 00EBCE0C2AA4CEEF00887639 /* ParraLoggerStackSymbols.swift */, + 007ED4692A6BFBEA0077E446 /* ParraLoggerThreadInfo+ParraDictionaryConvertible.swift */, + 00EBCE0E2AA4CF0C00887639 /* QualityOfService+Codable.swift */, + ); + path = Threads; + sourceTree = ""; + }; + 00EBCE122AA4D57100887639 /* Level */ = { + isa = PBXGroup; + children = ( + 00E7957D29A05CD6003FE68D /* ParraLogLevel.swift */, + 00EBCE132AA4D58F00887639 /* ParraLogLevel+ConsoleOutput.swift */, + ); + path = Level; + sourceTree = ""; + }; 00F5AAF228023EDB00A94C85 /* Types */ = { isa = PBXGroup; children = ( @@ -972,13 +1574,31 @@ ); dependencies = ( 001B7AFC27DE3A7600DEEECD /* PBXTargetDependency */, - 001B7AFE27DE3A7600DEEECD /* PBXTargetDependency */, ); name = ParraTests; productName = ParraTests; productReference = 001B7AF927DE3A7600DEEECD /* ParraTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + 00B42EAD2ADE1BCB00F365A5 /* TestRunner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 00B42EBF2ADE1BCC00F365A5 /* Build configuration list for PBXNativeTarget "TestRunner" */; + buildPhases = ( + 00B42EAA2ADE1BCB00F365A5 /* Sources */, + 00B42EAB2ADE1BCB00F365A5 /* Frameworks */, + 00B42EAC2ADE1BCB00F365A5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 00BF36C12B56CC9B0097905C /* PBXTargetDependency */, + 00719DBB2AF69CAA00310E25 /* PBXTargetDependency */, + ); + name = TestRunner; + productName = TestRunner; + productReference = 00B42EAE2ADE1BCB00F365A5 /* TestRunner.app */; + productType = "com.apple.product-type.application"; + }; 00DECFF4274C69F800DAF301 /* Demo */ = { isa = PBXNativeTarget; buildConfigurationList = 00DED009274C69FA00DAF301 /* Build configuration list for PBXNativeTarget "Demo" */; @@ -1006,16 +1626,20 @@ attributes = { BuildIndependentTargetsInParallel = 1; CLASSPREFIX = PAR; - LastSwiftUpdateCheck = 1320; - LastUpgradeCheck = 1430; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1520; ORGANIZATIONNAME = "Parra, Inc."; TargetAttributes = { 001B7AF127DE3A7600DEEECD = { CreatedOnToolsVersion = 13.2.1; + LastSwiftMigration = 1430; }; 001B7AF827DE3A7600DEEECD = { CreatedOnToolsVersion = 13.2.1; - TestTargetID = 00DECFF4274C69F800DAF301; + TestTargetID = 00B42EAD2ADE1BCB00F365A5; + }; + 00B42EAD2ADE1BCB00F365A5 = { + CreatedOnToolsVersion = 15.0; }; 00DECFF4274C69F800DAF301 = { CreatedOnToolsVersion = 13.1; @@ -1024,7 +1648,7 @@ }; }; buildConfigurationList = 00DECFF0274C69F800DAF301 /* Build configuration list for PBXProject "Parra" */; - compatibilityVersion = "Xcode 13.0"; + compatibilityVersion = "Xcode 15.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -1039,6 +1663,7 @@ 00DECFF4274C69F800DAF301 /* Demo */, 001B7AF127DE3A7600DEEECD /* Parra */, 001B7AF827DE3A7600DEEECD /* ParraTests */, + 00B42EAD2ADE1BCB00F365A5 /* TestRunner */, ); }; /* End PBXProject section */ @@ -1048,7 +1673,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 001B7B1927DE3AB000DEEECD /* Pacifico-Regular.ttf in Resources */, + 003621DD2B5B592D009EFE53 /* ParraAssets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1056,6 +1681,17 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 00B42EA72ADE176E00F365A5 /* Parra.xctestplan in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 00B42EAC2ADE1BCB00F365A5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 00B42EBD2ADE1BCC00F365A5 /* LaunchScreen.storyboard in Resources */, + 00B42EBA2ADE1BCC00F365A5 /* Assets.xcassets in Resources */, + 00B42EB82ADE1BCB00F365A5 /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1077,40 +1713,69 @@ files = ( 0039624A2A48B52100B8655F /* ParraStarControl.swift in Sources */, 00BD2FF82A47774300B4BEB2 /* URLSessionType.swift in Sources */, - 008878122979A3E20087AC1D /* UIApplication.swift in Sources */, + 00916DCA2AD35D9600B4856F /* ParraInstanceStorageConfiguration.swift in Sources */, + 0029AD302A490CDB00E30CCD /* Parra+InternalAnalytics.swift in Sources */, + 008878122979A3E20087AC1D /* UIApplicationState+ParraLogStringConvertible.swift in Sources */, 0039624E2A48B52100B8655F /* ParraRatingLabels.swift in Sources */, - 001B7B1327DE3A9B00DEEECD /* Parra+Endpoints.swift in Sources */, + 00BF36C72B59AB000097905C /* ParraFixture.swift in Sources */, + 007ED46A2A6BFBEA0077E446 /* ParraLoggerThreadInfo+ParraDictionaryConvertible.swift in Sources */, + 00BF36D12B5AC3EE0097905C /* FormFieldWithState.swift in Sources */, + 001B7B1327DE3A9B00DEEECD /* ParraNetworkManager+Endpoints.swift in Sources */, 0039625B2A48B53500B8655F /* ParraCardModalViewController.swift in Sources */, + 007ED46E2A6BFBF50077E446 /* ParraLogProcessedData+ParraDictionaryConvertible.swift in Sources */, + 00BF36C92B59AB190097905C /* FeedbackFormSelectFieldData+Fixtures.swift in Sources */, + 0087CA7C2A599916007D72FA /* ParraSessionManager+LogFormatters.swift in Sources */, + 0087CA942A59C0FB007D72FA /* ParraLoggerConsoleFormatOption.swift in Sources */, 0039624F2A48B52700B8655F /* ParraActionCardView.swift in Sources */, + 0087CAA22A59E507007D72FA /* ParraDiskUsage.swift in Sources */, 00A60B992932D35E00B7168A /* ParraConfiguration.swift in Sources */, 0039626A2A48B5AE00B8655F /* ParraQuestionKindView.swift in Sources */, 003962432A48B51800B8655F /* ParraConfigurableView.swift in Sources */, 0039623A2A48B50900B8655F /* ViewTimer.swift in Sources */, + 0087CA9C2A59E166007D72FA /* URL+diskUsage.swift in Sources */, + 0029AD372A4925C000E30CCD /* ParraStandardEvent.swift in Sources */, + 007ED4682A6BFBDC0077E446 /* ParraLoggerContext+ParraDictionaryConvertible.swift in Sources */, 00BD2FFC2A477D7700B4BEB2 /* ParraNotificationCenter.swift in Sources */, - 006DE9882A47547900521F5D /* ParraWrappedLogMessage.swift in Sources */, - 00E7957C29A05CBD003FE68D /* ParraLoggerConfig.swift in Sources */, + 00EAE2FE2AA0C8A2003FB41C /* ParraSessionGeneratorType.swift in Sources */, + 0087CA722A57B343007D72FA /* ParraLoggerCallSiteContext.swift in Sources */, + 006DE9882A47547900521F5D /* ParraLazyLogParam.swift in Sources */, + 00214C6A2AB3E2BC001C5313 /* ParraSanitizedDictionary.swift in Sources */, + 00BF36D32B5ACA460097905C /* FeedbackFormViewState.swift in Sources */, 003962642A48B55300B8655F /* CurrentCardInfo.swift in Sources */, + 00916DCC2AD35D9D00B4856F /* ParraInstanceNetworkConfiguration.swift in Sources */, + 0087CAA42A59E51B007D72FA /* ParraDiskUsage+ParraSessionParamDictionaryConvertible.swift in Sources */, 0039624C2A48B52100B8655F /* ParraBorderedRatingControl.swift in Sources */, 001B7B0E27DE3A9B00DEEECD /* Parra.swift in Sources */, + 00E269EE2A5257DC001A0F50 /* AuthorizationType.swift in Sources */, 0039625C2A48B53500B8655F /* ParraFeedbackFormView.swift in Sources */, + 0029AD332A49256300E30CCD /* ParraEvent.swift in Sources */, 0039623B2A48B50900B8655F /* TextValidator.swift in Sources */, - 0019CBC928FB37BC0015E703 /* Sequence.swift in Sources */, + 006654392A89AFB000CD04E6 /* ParraSessionUploadGenerator.swift in Sources */, + 00EBCE142AA4D58F00887639 /* ParraLogLevel+ConsoleOutput.swift in Sources */, + 0019CBC928FB37BC0015E703 /* Sequence+asyncMap.swift in Sources */, 003962562A48B52E00B8655F /* ParraCardCollectionViewCell.swift in Sources */, 001B7B1D27DE3ABC00DEEECD /* CredentialStorage.swift in Sources */, - 001B7B2227DE3AC200DEEECD /* FileManager.swift in Sources */, + 001B7B2227DE3AC200DEEECD /* FileManager+safePaths.swift in Sources */, 003962532A48B52E00B8655F /* ParraCardView+Transitions.swift in Sources */, - 00DA7FF0295DD28700109081 /* Parra+Analytics.swift in Sources */, + 00DA7FF0295DD28700109081 /* Parra+PublicAnalytics.swift in Sources */, + 0029AD392A4925D000E30CCD /* ParraInternalEvent.swift in Sources */, + 00EBCE162AA4F0D100887639 /* ParraLoggerExtraStyle.swift in Sources */, 00E15A23292AFB2B0049C2C7 /* SessionStorage.swift in Sources */, 003962722A48B5BF00B8655F /* ParraFeedbackFormTextFieldView.swift in Sources */, + 00916DC82AD35D8700B4856F /* ParraInstanceConfiguration.swift in Sources */, + 00EAE2F92A9F7D42003FB41C /* ParraSessionEventTarget.swift in Sources */, 001B7B1227DE3A9B00DEEECD /* ParraError.swift in Sources */, 003962512A48B52700B8655F /* ParraQuestionCardView.swift in Sources */, 003962552A48B52E00B8655F /* ParraCardView+Layout.swift in Sources */, + 0082DFCE2A53BA6700E7A91D /* Date+now.swift in Sources */, 001B7B0D27DE3A9B00DEEECD /* Parra+Constants.swift in Sources */, 00DA7FFE296367E400109081 /* ParraHeader.swift in Sources */, 001B7B1727DE3AA900DEEECD /* HttpMethod.swift in Sources */, + 007868A32AAE84CA00864CFE /* ParraSessionEventContext.swift in Sources */, 001B7B1427DE3A9B00DEEECD /* Parra+SyncEvents.swift in Sources */, 003962342A48B4EF00B8655F /* ParraCardAnswerHandler.swift in Sources */, - 006DE9862A47390700521F5D /* ParraGlobalState.swift in Sources */, + 006DE9862A47390700521F5D /* ParraState.swift in Sources */, + 00E269EC2A5257C0001A0F50 /* URLRequestHeaderField.swift in Sources */, 003962612A48B55300B8655F /* Direction.swift in Sources */, 00E15A29293269100049C2C7 /* UIViewController.swift in Sources */, 0039626F2A48B5AE00B8655F /* ParraImageKindView.swift in Sources */, @@ -1118,7 +1783,6 @@ 001B7B8027DE60F600DEEECD /* ParraDataManager.swift in Sources */, 0039623F2A48B51100B8655F /* UIView.swift in Sources */, 005D31EB29A83EC5001D0E0A /* CompletedCard.swift in Sources */, - 001B7B1027DE3A9B00DEEECD /* ParraLogger.swift in Sources */, 003962602A48B55300B8655F /* VisibleButtonOptions.swift in Sources */, 001B7B2527DE3AC200DEEECD /* JSONDecoder.swift in Sources */, 0039626B2A48B5AE00B8655F /* ParraBooleanKindView.swift in Sources */, @@ -1126,56 +1790,102 @@ 003962422A48B51100B8655F /* UIEdgeInsets.swift in Sources */, 001B7B2327DE3AC200DEEECD /* Models.swift in Sources */, 0039623D2A48B51100B8655F /* CAShapeLayer.swift in Sources */, + 0066540E2A7EBAAF00CD04E6 /* HTTPURLResponse+ParraDictionaryConvertible.swift in Sources */, + 0078689D2AAE54F800864CFE /* ParraSessionEventSyncPriority.swift in Sources */, + 00E269F02A525803001A0F50 /* Mimetype.swift in Sources */, 001B7B0F27DE3A9B00DEEECD /* Parra+Authentication.swift in Sources */, 001B7B1627DE3AA900DEEECD /* ParraCredential.swift in Sources */, + 00BF36D52B5ACAD20097905C /* FormFieldState.swift in Sources */, + 007ED4702A6BFC020077E446 /* Logger+scope.swift in Sources */, + 0087CA762A57B3C0007D72FA /* Logger+Timers.swift in Sources */, + 0087CA982A59DEC8007D72FA /* ProcessInfoThermalState+ParraLogStringConvertible.swift in Sources */, + 0087CA902A59B435007D72FA /* ParraLoggerOptions.swift in Sources */, + 0066540A2A7EAAFA00CD04E6 /* URLRequest+ParraDictionaryConvertible.swift in Sources */, + 007ED4742A6C458A0077E446 /* ParraSessionEvent.swift in Sources */, 003962392A48B4EF00B8655F /* ParraFeedback+Modals.swift in Sources */, 0039625F2A48B53D00B8655F /* CompletedCardDataStorage.swift in Sources */, + 006654102A7EBDB300CD04E6 /* NSURLRequest.CachePolicy+CustomStringConvertible.swift in Sources */, 0039625E2A48B53D00B8655F /* CardStorage.swift in Sources */, 001B7B2C27DE3AC800DEEECD /* ParraNetworkManager.swift in Sources */, + 006654122A80573400CD04E6 /* URLRequest.Attribution+CustomStringConvertible.swift in Sources */, 001B7B1F27DE3ABC00DEEECD /* PersistentStorageMedium.swift in Sources */, 00BD2FFE2A479BBA00B4BEB2 /* ParraConfigState.swift in Sources */, 003962542A48B52E00B8655F /* ParraCardTableViewCell.swift in Sources */, 003962442A48B52100B8655F /* ParraBorderedTextView.swift in Sources */, + 00214C6C2AB3E3A1001C5313 /* ParraDataSanitizer.swift in Sources */, 00DA7FE8295CAB8F00109081 /* AnyCodable.swift in Sources */, 00DA7FEA295CAB9700109081 /* AnyEncodable.swift in Sources */, + 00214C6E2AB62510001C5313 /* String+prefixes.swift in Sources */, 00A60B9B2932D37000B7168A /* ParraAuthenticationProvider.swift in Sources */, 006DE9822A472F9C00521F5D /* Endpoint.swift in Sources */, + 00C00ABB2A8A6BC2003B21D3 /* SessionStorageContext.swift in Sources */, 0019CBC328F639040015E703 /* ParraQuestionAppArea.swift in Sources */, + 0087CA9A2A59DFCC007D72FA /* ProcessInfoPowerState+ParraLogStringConvertible.swift in Sources */, + 0066540C2A7EAEF000CD04E6 /* ParraErrorWithExtra.swift in Sources */, + 003621DB2B5B569F009EFE53 /* ParraLogoType.swift in Sources */, + 00EBCE0A2AA4C1EE00887639 /* ParraLoggerScopeType.swift in Sources */, + 00E269E42A523000001A0F50 /* ParraFeedbackPopupState.swift in Sources */, + 0087CA7E2A5999D7007D72FA /* ParraSessionManager+LogProcessors.swift in Sources */, 0039625A2A48B53500B8655F /* ParraCardDrawerViewController.swift in Sources */, + 0087CAA02A59E4F1007D72FA /* ParraSanitizedDictionaryConvertible.swift in Sources */, + 0040DEAD2AA226A3007E2190 /* UIViewController+defaultLogger.swift in Sources */, 00707D1B27FA0A07004E9567 /* URLSession.swift in Sources */, - 001B7B2627DE3AC200DEEECD /* UIFont.swift in Sources */, + 001B7B2627DE3AC200DEEECD /* UIFont+registration.swift in Sources */, 003C214727DE855A001CAE03 /* ItemStorage.swift in Sources */, 00BD2FF22A4776E700B4BEB2 /* NetworkManagerType.swift in Sources */, 003962482A48B52100B8655F /* ParraPaddedBaseButton.swift in Sources */, + 00E269E82A523CD5001A0F50 /* ParraConfigurationOption.swift in Sources */, + 0087CA742A57B38E007D72FA /* ParraLogMeasurementFormat.swift in Sources */, + 0087CA532A5466CF007D72FA /* ParraNetworkManager+ParraUrlSessionDelegate.swift in Sources */, + 0087CA4F2A54668B007D72FA /* ParraUrlSessionDelegate.swift in Sources */, 003962372A48B4EF00B8655F /* ParraFeedback.swift in Sources */, - 00DA7FF9295F2A1600109081 /* URL.swift in Sources */, + 0029AD2B2A48DCE100E30CCD /* LoggerHelpers.swift in Sources */, + 003621EB2B5C2A62009EFE53 /* FeedbackFormViewState+Fixtures.swift in Sources */, + 00BF36CB2B59B01D0097905C /* FeedbackFormField+Fixtures.swift in Sources */, + 00DA7FF9295F2A1600109081 /* URL+safePaths.swift in Sources */, + 007868992AAE4DE300864CFE /* ParraLogEvent.swift in Sources */, 001B7B2027DE3ABC00DEEECD /* UserDefaultsStorage.swift in Sources */, - 00901F2D2991E3430071647F /* Encodable.swift in Sources */, - 00DA7FF5295DD2EC00109081 /* ParraSessionEvent.swift in Sources */, + 007ED4802A6C9D370077E446 /* CallStackFrame.swift in Sources */, 003962662A48B55300B8655F /* ParraTextConfig.swift in Sources */, 001B7B2727DE3AC200DEEECD /* JSONEncoder.swift in Sources */, - 0018A60E28D77FF2008CC97E /* URLComponents.swift in Sources */, + 0018A60E28D77FF2008CC97E /* URLComponents+queryItems.swift in Sources */, 003962712A48B5BF00B8655F /* ParraFeedbackFormSelectFieldView.swift in Sources */, + 0066543B2A8A5F0E00CD04E6 /* LoggerFormatters.swift in Sources */, 003962462A48B52100B8655F /* ParraPaddedBaseTextField.swift in Sources */, 0039623E2A48B51100B8655F /* NSAttributedString.swift in Sources */, + 0087CA8E2A59B41C007D72FA /* ParraLoggerCallSiteStyleOptions.swift in Sources */, 003962382A48B4EF00B8655F /* ParraFeedbackDataManager+Keys.swift in Sources */, 0019CBC528FAE0E80015E703 /* FailableDecodable.swift in Sources */, 003962692A48B5AE00B8655F /* ParraChoiceKindView.swift in Sources */, + 006654142A8066BC00CD04E6 /* ParraSessionUpload.swift in Sources */, + 0087CAAD2A5A2B1A007D72FA /* ParraLogMarkerMeasurement.swift in Sources */, + 0087CA512A5466A6007D72FA /* ParraNetworkManagerUrlSessionDelegateProxy.swift in Sources */, + 00EBCE082AA4C1E300887639 /* ParraLogContext.swift in Sources */, 003962452A48B52100B8655F /* ParraBorderedButton.swift in Sources */, 001B7B1127DE3A9B00DEEECD /* ParraModule.swift in Sources */, + 003621D72B5B563C009EFE53 /* ParraLogo.swift in Sources */, 003962352A48B4EF00B8655F /* ParraFeedback+Constants.swift in Sources */, + 00EAE2F72A9BE71F003FB41C /* URL+helpers.swift in Sources */, 003962632A48B55300B8655F /* ParraCardViewConfig.swift in Sources */, - 00E15A2B29326FBE0049C2C7 /* UIWindow.swift in Sources */, + 0087CA7A2A58FAA2007D72FA /* Logger+StaticLevels.swift in Sources */, + 00E15A2B29326FBE0049C2C7 /* UIWindow+topViewController.swift in Sources */, + 003621E92B5C2784009EFE53 /* ParraLogoButton.swift in Sources */, 00BD2FF62A47772A00B4BEB2 /* AuthenticatedRequestResult.swift in Sources */, + 0087CA682A561F72007D72FA /* Logger+Levels.swift in Sources */, + 0087CA852A59A046007D72FA /* QualityOfService+ParraLogDescription.swift in Sources */, 005D31ED29A8561D001D0E0A /* QuestionAnswer.swift in Sources */, 001B7B8E27DE806100DEEECD /* DataStorageMedium.swift in Sources */, + 00E269EA2A524842001A0F50 /* ParraModuleStateAccessor.swift in Sources */, + 007ED4722A6C2C810077E446 /* ParraWrappedEvent.swift in Sources */, 006DE9802A472F1B00521F5D /* Parra+Push.swift in Sources */, 003962652A48B55300B8655F /* TextValidationError.swift in Sources */, 003962362A48B4EF00B8655F /* ParraFeedback+Notifications.swift in Sources */, 00DA7FF3295DD2D500109081 /* ParraSession.swift in Sources */, 00E15A2D293276940049C2C7 /* Thread.swift in Sources */, + 0087CA6E2A57B308007D72FA /* ParraLogMarker.swift in Sources */, 003962402A48B51100B8655F /* Int.swift in Sources */, 001B7B2927DE3AC800DEEECD /* ParraSyncManager.swift in Sources */, + 0087CA8C2A59B408007D72FA /* ParraLoggerLevelStyle.swift in Sources */, 0039626C2A48B5AE00B8655F /* ParraCheckboxKindView.swift in Sources */, 00DA7FFC296367D400109081 /* RequestConfig.swift in Sources */, 0039626D2A48B5AE00B8655F /* ParraLongTextKindView.swift in Sources */, @@ -1185,32 +1895,50 @@ 0039624D2A48B52100B8655F /* ParraImageButton.swift in Sources */, 00497D7929F88CC4004EF5AB /* ParraSessionsResponse.swift in Sources */, 001B7B8827DE712200DEEECD /* ParraStorageModule.swift in Sources */, + 00BC73162AC27BD300605873 /* Documentation.docc in Sources */, 00BD2FFA2A47778E00B4BEB2 /* NotificationCenterType.swift in Sources */, + 00C00ABD2A8A6BD1003B21D3 /* SessionReader.swift in Sources */, 003962702A48B5BF00B8655F /* ParraFeedbackFormFieldView.swift in Sources */, - 00BDB2B128BD822700C16649 /* URLRequest.swift in Sources */, + 00BDB2B128BD822700C16649 /* URLRequest+headers.swift in Sources */, 00E7957E29A05CD6003FE68D /* ParraLogLevel.swift in Sources */, + 0087CAAB2A59F3B4007D72FA /* ParraLogData.swift in Sources */, 003962502A48B52700B8655F /* ParraEmptyCardView.swift in Sources */, - 00E7958029A12E29003FE68D /* ParraDefaultLogger.swift in Sources */, - 00DA7FF7295DD2FD00109081 /* ParraSessionEventType.swift in Sources */, + 00EBCE0F2AA4CF0C00887639 /* QualityOfService+Codable.swift in Sources */, 0039623C2A48B51100B8655F /* UIColor.swift in Sources */, - 00731EFB2A182639004010A3 /* Task.swift in Sources */, + 00731EFB2A182639004010A3 /* Task+sleep.swift in Sources */, + 0087CA702A57B331007D72FA /* ParraLoggerBackend.swift in Sources */, 0039625D2A48B53500B8655F /* ParraFeedbackFormViewController.swift in Sources */, - 00E15A212929BC790049C2C7 /* UIDeviceOrientation.swift in Sources */, + 00E15A212929BC790049C2C7 /* UIDeviceOrientation+ParraLogStringConvertible.swift in Sources */, + 0087CA8A2A59B3F9007D72FA /* ParraLoggerTimestampStyle.swift in Sources */, + 0087CA832A599FDD007D72FA /* ParraLogStringConvertible.swift in Sources */, 003962672A48B5AE00B8655F /* ParraRatingKindView.swift in Sources */, 003962412A48B51100B8655F /* Array.swift in Sources */, 00DA7FE5295C95C400109081 /* Syncable.swift in Sources */, 003962582A48B53500B8655F /* ParraCardModal.swift in Sources */, + 007ED4762A6C8BDF0077E446 /* CallStackParser.swift in Sources */, + 0087CA662A56114C007D72FA /* Logger.swift in Sources */, 001B7B1827DE3AA900DEEECD /* GeneratedTypes.swift in Sources */, - 00DEF7262A3FE659006B0DF3 /* UIDevice.swift in Sources */, + 00DEF7262A3FE659006B0DF3 /* UIDevice+modelCode.swift in Sources */, 0039624B2A48B52100B8655F /* SelectableButton.swift in Sources */, 003962472A48B52100B8655F /* ParraStar.swift in Sources */, + 00BF36CD2B59BBDE0097905C /* FeedbackFormTextFieldData+Fixtures.swift in Sources */, + 00EBCE0D2AA4CEEF00887639 /* ParraLoggerStackSymbols.swift in Sources */, 003962572A48B52E00B8655F /* ParraCardView.swift in Sources */, + 0029AD3C2A49291200E30CCD /* StringManipulators.swift in Sources */, 001B7B1A27DE3ABC00DEEECD /* FileSystemStorage.swift in Sources */, - 00BD2FEE2A4771D700B4BEB2 /* SyncState.swift in Sources */, + 00BD2FEE2A4771D700B4BEB2 /* ParraSyncState.swift in Sources */, + 0087CA552A54670A007D72FA /* EmptyJsonObjects.swift in Sources */, + 0087CA872A59A0D5007D72FA /* ParraLoggerThreadInfo.swift in Sources */, 003962522A48B52700B8655F /* ParraCardItemView.swift in Sources */, + 00EAE2F52A9BC3DB003FB41C /* FileHandleType.swift in Sources */, + 007ED4672A6BFBDC0077E446 /* ParraLoggerContext.swift in Sources */, 005E38A727FA3A8700F32F96 /* Parra+Notifications.swift in Sources */, + 0087CA922A59BA51007D72FA /* ParraLoggerEnvironment.swift in Sources */, 00E15A1F2929B7C10049C2C7 /* ParraSessionManager.swift in Sources */, + 007ED46D2A6BFBF50077E446 /* ParraLogProcessedData.swift in Sources */, 003962622A48B55300B8655F /* ParraShadowConfig.swift in Sources */, + 0087CA962A59D82D007D72FA /* UIDeviceBatteryState+ParraLogStringConvertible.swift in Sources */, + 00EAE2FB2AA0C196003FB41C /* ParraSessionGenerator.swift in Sources */, 0039626E2A48B5AE00B8655F /* ParraShortTextKindView.swift in Sources */, 003962682A48B5AE00B8655F /* ParraStarKindView.swift in Sources */, 00DA7FEC295CAB9E00109081 /* AnyDecodable.swift in Sources */, @@ -1221,23 +1949,48 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 00BD30002A47A70800B4BEB2 /* Parra+PushTests.swift in Sources */, + 0082DFC42A53745F00E7A91D /* Parra+AuthenticationTests.swift in Sources */, + 00E26A002A53110F001A0F50 /* MockURLSession.swift in Sources */, + 00E269F62A526776001A0F50 /* Endpoint+Mocks.swift in Sources */, + 0082DFC12A53736F00E7A91D /* MockParraNetworkManager.swift in Sources */, + 00E26A032A53120F001A0F50 /* TestData.swift in Sources */, 00F5AAF428023EF300A94C85 /* GeneratedTypes+Swift.swift in Sources */, 001559AA27E41A8A006450BE /* ParraStorageModuleTests.swift in Sources */, + 00E26A072A534334001A0F50 /* ParraState.swift in Sources */, + 0082DFC72A53883D00E7A91D /* Parra+PushTests.swift in Sources */, 0015599C27E40B13006450BE /* FileSystemStorageTests.swift in Sources */, + 0082DFBD2A535FDD00E7A91D /* Data.swift in Sources */, 001559AF27E7E76C006450BE /* CredentialStorageTests.swift in Sources */, 001559B127E7EAC0006450BE /* ParraDataManagerTests.swift in Sources */, 001559A427E412FD006450BE /* UserDefaultsStorageTests.swift in Sources */, + 00E269FE2A5310D5001A0F50 /* URLSessionDataTaskType.swift in Sources */, 001559A827E417D4006450BE /* ParraLoggerTests.swift in Sources */, + 00916DC02ACCEE7600B4856F /* MockedParraTestCase.swift in Sources */, 00BDB2AF28BC24E700C16649 /* ParraCredentialTests.swift in Sources */, - 00320A5B27ED4EF0001EB323 /* Parra+AuthenticationTests.swift in Sources */, + 0082DFCA2A53939A00E7A91D /* ParraSyncManagerTests.swift in Sources */, + 00916DC22AD2EFCD00B4856F /* SignpostTestObserver.swift in Sources */, + 00E269FA2A530D69001A0F50 /* ParraEndpoint+CaseIterable.swift in Sources */, + 00E269F82A530771001A0F50 /* XCTestCase.swift in Sources */, + 00E26A052A53132E001A0F50 /* String.swift in Sources */, + 0082DFCC2A53B6F100E7A91D /* ParraSessionManagerTests.swift in Sources */, 001559B527E7EAD9006450BE /* ParraNetworkManagerTests.swift in Sources */, + 0082DFC92A538A5400E7A91D /* ParraAuthenticationProviderType.swift in Sources */, + 0029AD2E2A48E11300E30CCD /* LoggerHelpersTests.swift in Sources */, 001B7B0127DE3A7600DEEECD /* ParraTests.swift in Sources */, + 00D04C612AD437850024CA4E /* ParraBaseMock.swift in Sources */, 001559A227E40D56006450BE /* PersistentStorageTestHelpers.swift in Sources */, 0015599F27E40B71006450BE /* FileManagerTests.swift in Sources */, - 00707D1727F884BB004E9567 /* ParraNetworkManagerTests+Mocks.swift in Sources */, - 005E38A527FA1E0200F32F96 /* NetworkTestHelpers.swift in Sources */, - 001559B327E7EACC006450BE /* ParraSyncManagerTests.swift in Sources */, + 00D04C5F2AD429620024CA4E /* SessionReaderTests.swift in Sources */, + 0082DFC62A5374BD00E7A91D /* MockParra.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 00B42EAA2ADE1BCB00F365A5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 00B42EB12ADE1BCB00F365A5 /* AppDelegate.swift in Sources */, + 00B42EB32ADE1BCB00F365A5 /* SceneDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1264,19 +2017,40 @@ target = 001B7AF127DE3A7600DEEECD /* Parra */; targetProxy = 001B7AFB27DE3A7600DEEECD /* PBXContainerItemProxy */; }; - 001B7AFE27DE3A7600DEEECD /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 00DECFF4274C69F800DAF301 /* Demo */; - targetProxy = 001B7AFD27DE3A7600DEEECD /* PBXContainerItemProxy */; - }; 001B7B0427DE3A7600DEEECD /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 001B7AF127DE3A7600DEEECD /* Parra */; targetProxy = 001B7B0327DE3A7600DEEECD /* PBXContainerItemProxy */; }; + 00719DBB2AF69CAA00310E25 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 001B7AF127DE3A7600DEEECD /* Parra */; + targetProxy = 00719DBA2AF69CAA00310E25 /* PBXContainerItemProxy */; + }; + 00BF36C12B56CC9B0097905C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 001B7AF827DE3A7600DEEECD /* ParraTests */; + targetProxy = 00BF36C02B56CC9B0097905C /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ + 00B42EB62ADE1BCB00F365A5 /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 00B42EB72ADE1BCB00F365A5 /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 00B42EBB2ADE1BCC00F365A5 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 00B42EBC2ADE1BCC00F365A5 /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; 00DECFFE274C69F800DAF301 /* Main.storyboard */ = { isa = PBXVariantGroup; children = ( @@ -1299,19 +2073,25 @@ 001B7B0827DE3A7600DEEECD /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + CLANG_ENABLE_MODULES = YES; + CLANG_WARN_COMPLETION_HANDLER_MISUSE = YES; + CODE_SIGN_ENTITLEMENTS = TestRunner/TestRunner.entitlements; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 10; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 43225XRA2T; + DOCC_EXTRACT_OBJC_INFO_FOR_SWIFT_SYMBOLS = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_MODULE_VERIFIER = YES; + GCC_WARN_SHADOW = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_FILE = ""; + INFOPLIST_KEY_NSHumanReadableCopyright = "Parra Inc."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1320,12 +2100,16 @@ MARKETING_VERSION = 1.2.0; MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++17"; + OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = com.parra.Parra; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; + RUN_DOCUMENTATION_COMPILER = YES; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; @@ -1336,19 +2120,25 @@ 001B7B0927DE3A7600DEEECD /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + CLANG_ENABLE_MODULES = YES; + CLANG_WARN_COMPLETION_HANDLER_MISUSE = YES; + CODE_SIGN_ENTITLEMENTS = TestRunner/TestRunner.entitlements; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 10; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 43225XRA2T; + DOCC_EXTRACT_OBJC_INFO_FOR_SWIFT_SYMBOLS = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_MODULE_VERIFIER = YES; + GCC_WARN_SHADOW = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_FILE = ""; + INFOPLIST_KEY_NSHumanReadableCopyright = "Parra Inc."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1357,12 +2147,15 @@ MARKETING_VERSION = 1.2.0; MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++17"; + OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = com.parra.Parra; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; + RUN_DOCUMENTATION_COMPILER = YES; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; @@ -1374,21 +2167,23 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 43225XRA2T; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.2; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.parra.ParraTests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 5.8; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Demo.app/Demo"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TestRunner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TestRunner"; }; name = Debug; }; @@ -1396,21 +2191,102 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 43225XRA2T; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.2; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.parra.ParraTests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.8; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TestRunner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TestRunner"; + }; + name = Release; + }; + 00B42EC02ADE1BCC00F365A5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 43225XRA2T; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TestRunner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Parra Tests"; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.parra.ParraTests.Runner; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 00B42EC12ADE1BCC00F365A5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 43225XRA2T; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TestRunner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Parra Tests"; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.parra.ParraTests.Runner; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Demo.app/Demo"; }; name = Release; }; @@ -1419,6 +2295,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; @@ -1452,6 +2329,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -1466,13 +2344,14 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + TEST_HOST = TestRunner; }; name = Debug; }; @@ -1481,6 +2360,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; @@ -1514,6 +2394,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -1522,12 +2403,13 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; + TEST_HOST = TestRunner; VALIDATE_PRODUCT = YES; }; name = Release; @@ -1540,9 +2422,10 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Demo/Demo.entitlements; + CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 43225XRA2T; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Demo/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Parra Demo"; @@ -1551,7 +2434,7 @@ INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1560,11 +2443,15 @@ PRODUCT_BUNDLE_IDENTIFIER = "com.parra.parra-ios-sdk-demo"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "Demo/Demo-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 5.8; TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = ""; }; name = Debug; }; @@ -1576,9 +2463,10 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Demo/Demo.entitlements; + CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 43225XRA2T; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Demo/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Parra Demo"; @@ -1587,7 +2475,7 @@ INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1596,10 +2484,14 @@ PRODUCT_BUNDLE_IDENTIFIER = "com.parra.parra-ios-sdk-demo"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "Demo/Demo-Bridging-Header.h"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 5.8; TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = ""; }; name = Release; }; @@ -1624,6 +2516,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 00B42EBF2ADE1BCC00F365A5 /* Build configuration list for PBXNativeTarget "TestRunner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 00B42EC02ADE1BCC00F365A5 /* Debug */, + 00B42EC12ADE1BCC00F365A5 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 00DECFF0274C69F800DAF301 /* Build configuration list for PBXProject "Parra" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Parra.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme b/Parra.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme index 87ce3ca37..b101d8b4b 100644 --- a/Parra.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme +++ b/Parra.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme @@ -1,6 +1,6 @@ - - - - - - - - - - - - - - + shouldUseLaunchSchemeArgsEnv = "YES" + shouldAutocreateTestPlan = "YES"> + + diff --git a/Parra.xcodeproj/xcshareddata/xcschemes/Parra.xcscheme b/Parra.xcodeproj/xcshareddata/xcschemes/Parra.xcscheme new file mode 100644 index 000000000..b77cc3116 --- /dev/null +++ b/Parra.xcodeproj/xcshareddata/xcschemes/Parra.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Parra.xcodeproj/xcshareddata/xcschemes/ParraCoreTests.xcscheme b/Parra.xcodeproj/xcshareddata/xcschemes/ParraCoreTests.xcscheme deleted file mode 100644 index 6b4cbee17..000000000 --- a/Parra.xcodeproj/xcshareddata/xcschemes/ParraCoreTests.xcscheme +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Parra.xcodeproj/xcshareddata/xcschemes/ParraCore.xcscheme b/Parra.xcodeproj/xcshareddata/xcschemes/ParraTests.xcscheme similarity index 73% rename from Parra.xcodeproj/xcshareddata/xcschemes/ParraCore.xcscheme rename to Parra.xcodeproj/xcshareddata/xcschemes/ParraTests.xcscheme index e40ea6615..cb1088b20 100644 --- a/Parra.xcodeproj/xcshareddata/xcschemes/ParraCore.xcscheme +++ b/Parra.xcodeproj/xcshareddata/xcschemes/ParraTests.xcscheme @@ -1,22 +1,32 @@ + LastUpgradeVersion = "1520" + version = "2.2"> + + + + + buildForProfiling = "NO" + buildForArchiving = "NO" + buildForAnalyzing = "NO"> @@ -29,7 +39,7 @@ shouldUseLaunchSchemeArgsEnv = "YES"> @@ -50,11 +60,13 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + disableMainThreadChecker = "YES" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> - - - - diff --git a/Parra.xcodeproj/xcshareddata/xcschemes/ParraFeedback.xcscheme b/Parra.xcodeproj/xcshareddata/xcschemes/TestRunner.xcscheme similarity index 70% rename from Parra.xcodeproj/xcshareddata/xcschemes/ParraFeedback.xcscheme rename to Parra.xcodeproj/xcshareddata/xcschemes/TestRunner.xcscheme index f47ae474d..239032a7b 100644 --- a/Parra.xcodeproj/xcshareddata/xcschemes/ParraFeedback.xcscheme +++ b/Parra.xcodeproj/xcshareddata/xcschemes/TestRunner.xcscheme @@ -1,6 +1,6 @@ @@ -26,13 +26,20 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES" - shouldAutocreateTestPlan = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + @@ -56,15 +63,16 @@ savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES"> - + - + diff --git a/Parra/Core/Instance Config/ParraInstanceConfiguration.swift b/Parra/Core/Instance Config/ParraInstanceConfiguration.swift new file mode 100644 index 000000000..8a2a690f3 --- /dev/null +++ b/Parra/Core/Instance Config/ParraInstanceConfiguration.swift @@ -0,0 +1,50 @@ +// +// ParraInstanceConfiguration.swift +// Parra +// +// Created by Mick MacCallum on 10/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal struct ParraInstanceConfiguration { + internal let networkConfiguration: ParraInstanceNetworkConfiguration + internal let storageConfiguration: ParraInstanceStorageConfiguration + + static let `default`: ParraInstanceConfiguration = { + let diskCacheUrl = ParraDataManager.Path.networkCachesDirectory + let baseStorageUrl = ParraDataManager.Path.parraDirectory + let storageDirectoryName = ParraDataManager.Directory.storageDirectoryName + + // Cache may reject image entries if they are greater than 10% of the cache's size + // so these need to reflect that. + let networkCache = URLCache( + memoryCapacity: 50 * 1024 * 1024, + diskCapacity: 300 * 1024 * 1024, + directory: diskCacheUrl + ) + + let sessionConfig = URLSessionConfiguration.default + sessionConfig.urlCache = networkCache + sessionConfig.requestCachePolicy = .returnCacheDataElseLoad + + let urlSession = URLSession(configuration: sessionConfig) + + return ParraInstanceConfiguration( + networkConfiguration: ParraInstanceNetworkConfiguration( + urlSession: urlSession, + jsonEncoder: .parraEncoder, + jsonDecoder: .parraDecoder + ), + storageConfiguration: ParraInstanceStorageConfiguration( + baseDirectory: baseStorageUrl, + storageDirectoryName: storageDirectoryName, + sessionJsonEncoder: .parraEncoder, + sessionJsonDecoder: .parraDecoder, + eventJsonEncoder: .spaceOptimizedEncoder, + eventJsonDecoder: .spaceOptimizedDecoder + ) + ) + }() +} diff --git a/Parra/Core/Instance Config/ParraInstanceNetworkConfiguration.swift b/Parra/Core/Instance Config/ParraInstanceNetworkConfiguration.swift new file mode 100644 index 000000000..88eab60c9 --- /dev/null +++ b/Parra/Core/Instance Config/ParraInstanceNetworkConfiguration.swift @@ -0,0 +1,15 @@ +// +// ParraInstanceNetworkConfiguration.swift +// Parra +// +// Created by Mick MacCallum on 10/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal struct ParraInstanceNetworkConfiguration { + internal let urlSession: URLSession + internal let jsonEncoder: JSONEncoder + internal let jsonDecoder: JSONDecoder +} diff --git a/Parra/Core/Instance Config/ParraInstanceStorageConfiguration.swift b/Parra/Core/Instance Config/ParraInstanceStorageConfiguration.swift new file mode 100644 index 000000000..d3ddd9c70 --- /dev/null +++ b/Parra/Core/Instance Config/ParraInstanceStorageConfiguration.swift @@ -0,0 +1,18 @@ +// +// ParraInstanceStorageConfiguration.swift +// Parra +// +// Created by Mick MacCallum on 10/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal struct ParraInstanceStorageConfiguration { + internal let baseDirectory: URL + internal let storageDirectoryName: String + internal let sessionJsonEncoder: JSONEncoder + internal let sessionJsonDecoder: JSONDecoder + internal let eventJsonEncoder: JSONEncoder + internal let eventJsonDecoder: JSONDecoder +} diff --git a/Parra/Parra+Authentication.swift b/Parra/Core/Parra+Authentication.swift similarity index 56% rename from Parra/Parra+Authentication.swift rename to Parra/Core/Parra+Authentication.swift index 057a8ad52..567440d87 100644 --- a/Parra/Parra+Authentication.swift +++ b/Parra/Core/Parra+Authentication.swift @@ -7,15 +7,9 @@ import Foundation +fileprivate let logger = Logger(category: "Authentication Extensions") + public extension Parra { - @MainActor - internal class func deinitialize() async { - await shared.networkManager.updateAuthenticationProvider(nil) - await ParraConfigState.shared.resetState() - await ParraGlobalState.shared.unregisterModule(module: shared) - await ParraGlobalState.shared.deinitialize() - await shared.sessionManager.endSession() - } /// Initializes the Parra SDK using the provided configuration and auth provider. This method should be invoked as /// early as possible inside of applicationDidFinishLaunchingWithOptions. @@ -23,12 +17,25 @@ public extension Parra { /// - authProvider: An async function that is expected to return a ParraCredential object containing a user's /// access token. This function will be invoked automatically whenever the user's credential is missing or /// expired and Parra needs to refresh the authentication state for your user. - class func initialize(config: ParraConfiguration = .default, - authProvider: ParraAuthenticationProviderType) { + static func initialize( + options: [ParraConfigurationOption] = [], + authProvider: ParraAuthenticationProviderType + ) { + getSharedInstance().initialize( + options: options, + authProvider: authProvider + ) + } + + internal func initialize( + options: [ParraConfigurationOption] = [], + authProvider: ParraAuthenticationProviderType + ) { Task { @MainActor in - await initialize(config: config, authProvider: authProvider) + await initialize(options: options, authProvider: authProvider) } } + /// Initializes the Parra SDK using the provided configuration and auth provider. This method should be invoked as /// early as possible inside of applicationDidFinishLaunchingWithOptions. /// - Parameters: @@ -36,30 +43,43 @@ public extension Parra { /// access token. This function will be invoked automatically whenever the user's credential is missing or /// expired and Parra needs to refresh the authentication state for your user. @MainActor - class func initialize(config: ParraConfiguration = .default, - authProvider: ParraAuthenticationProviderType) async { - if await ParraGlobalState.shared.isInitialized() { - parraLogWarn("Parra.initialize called more than once. Subsequent calls are ignored") + static func initialize( + options: [ParraConfigurationOption] = [], + authProvider: ParraAuthenticationProviderType + ) async { + await getSharedInstance().initialize( + options: options, + authProvider: authProvider + ) + } + + @MainActor + internal func initialize( + options: [ParraConfigurationOption] = [], + authProvider: ParraAuthenticationProviderType + ) async { + if await state.isInitialized() { + logger.warn("Parra.initialize called more than once. Subsequent calls are ignored") return } - var newConfig = config + var newConfig = ParraConfiguration(options: options) let (tenantId, applicationId, authenticationProvider) = withAuthenticationMiddleware( for: authProvider - ) { [weak shared] success in - guard let shared else { + ) { [weak self] success in + guard let self else { return } Task { if success { - shared.addEventObservers() - shared.syncManager.startSyncTimer() + self.addEventObservers() + self.syncManager.startSyncTimer() } else { - await shared.dataManager.updateCredential(credential: nil) - shared.syncManager.stopSyncTimer() + await self.dataManager.updateCredential(credential: nil) + self.syncManager.stopSyncTimer() } } } @@ -67,51 +87,46 @@ public extension Parra { newConfig.setTenantId(tenantId) newConfig.setApplicationId(applicationId) - let modules: [ParraModule] = [Parra.shared, ParraFeedback.shared] - for module in modules { - await ParraGlobalState.shared.registerModule(module: module) - } - - await ParraConfigState.shared.updateState(newConfig) - await shared.networkManager.updateAuthenticationProvider(authenticationProvider) - await ParraGlobalState.shared.initialize() + await state.registerModule(module: self) + await configState.updateState(newConfig) + sessionManager.updateLoggerOptions(loggerOptions: newConfig.loggerOptions) + await sessionManager.initializeSessions() + await networkManager.updateAuthenticationProvider(authenticationProvider) + await state.initialize() - // Generally nothing that can generate events should happen before this call. Even logs - await shared.sessionManager.createSessionIfNotExists() - parraLogInfo("Parra SDK Initialized") + logger.info("Parra SDK Initialized") do { - let _ = try await shared.networkManager.refreshAuthentication() + let _ = try await networkManager.refreshAuthentication() await performPostAuthRefreshActions() } catch let error { - parraLogError("Authentication handler in call to Parra.initialize failed", error) + logger.error("Authentication handler in call to Parra.initialize failed", error) } } - private class func performPostAuthRefreshActions() async { - parraLogDebug("Performing push authentication refresh actions.") + private func performPostAuthRefreshActions() async { + logger.debug("Performing push authentication refresh actions.") await uploadCachedPushNotificationToken() } - private class func uploadCachedPushNotificationToken() async { - guard let token = await ParraGlobalState.shared.getCachedTemporaryPushToken() else { - parraLogTrace("No push notification token is cached. Skipping upload.") + private func uploadCachedPushNotificationToken() async { + guard let token = await state.getCachedTemporaryPushToken() else { + logger.trace("No push notification token is cached. Skipping upload.") return } - parraLogTrace("Push notification token was cached. Attempting upload.") + logger.trace("Push notification token was cached. Attempting upload.") await uploadDevicePushToken(token) } @MainActor - internal class func withAuthenticationMiddleware( + internal func withAuthenticationMiddleware( for authProvider: ParraAuthenticationProviderType, onAuthenticationRefresh: @escaping (_ success: Bool) -> Void ) -> (String, String, ParraAuthenticationProviderFunction) { - switch authProvider { case .default(let tenantId, let applicationId, let authProvider): @@ -128,9 +143,9 @@ public extension Parra { } }) case .publicKey(let tenantId, let applicationId, let apiKeyId, let userIdProvider): - return (tenantId, applicationId, { [weak shared] () async throws -> String in + return (tenantId, applicationId, { [weak self] () async throws -> String in do { - guard let networkManager = shared?.networkManager else { + guard let networkManager = self?.networkManager else { throw ParraError.unknown } diff --git a/Parra/Parra+Constants.swift b/Parra/Core/Parra+Constants.swift similarity index 51% rename from Parra/Parra+Constants.swift rename to Parra/Core/Parra+Constants.swift index 272c5f248..45c767378 100644 --- a/Parra/Parra+Constants.swift +++ b/Parra/Core/Parra+Constants.swift @@ -29,5 +29,30 @@ public extension Parra { internal static let parraApiRoot = URL(string: "https://api.parra.io/v1/")! internal static let backgroundTaskName = "com.parra.session.backgroundtask" internal static let backgroundTaskDuration: TimeInterval = 25.0 + + internal enum Formatters { + static let iso8601Formatter = ISO8601DateFormatter() + + static let dateComponentsFormatter = DateComponentsFormatter() + static let dateIntervalFormatter = DateIntervalFormatter() + + static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + + formatter.locale = .current + formatter.timeZone = .current + + return formatter + }() + + static let rfc3339DateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" + formatter.timeZone = TimeZone(secondsFromGMT: 0) + + return formatter + }() + } } } diff --git a/Parra/Parra+Notifications.swift b/Parra/Core/Parra+Notifications.swift similarity index 100% rename from Parra/Parra+Notifications.swift rename to Parra/Core/Parra+Notifications.swift diff --git a/Parra/Parra+Push.swift b/Parra/Core/Parra+Push.swift similarity index 59% rename from Parra/Parra+Push.swift rename to Parra/Core/Parra+Push.swift index c647ebd96..4e3dd3888 100644 --- a/Parra/Parra+Push.swift +++ b/Parra/Core/Parra+Push.swift @@ -8,6 +8,8 @@ import Foundation +fileprivate let logger = Logger(category: "Push Extensions") + public extension Parra { /// This method should be invoked from the UIApplication delegate method @@ -19,23 +21,29 @@ public extension Parra { /// initilizing Parra. For more information, see: /// https://developer.apple.com/documentation/usernotifications/registering_your_app_with_apns @MainActor - class func registerDevicePushToken(_ token: Data) { - parraLogDebug("Registering device push token") + static func registerDevicePushToken(_ token: Data) { + Task { + await getSharedInstance().registerDevicePushToken(token) + } + } + + internal func registerDevicePushToken(_ token: Data) async { + logger.debug("Registering device push token") let tokenString = token.map { String(format: "%02.2hhx", $0) }.joined() - Task { - await registerDevicePushTokenString(tokenString) - } + await registerDevicePushTokenString(tokenString) } - internal class func registerDevicePushTokenString(_ tokenString: String) async { - guard await ParraGlobalState.shared.isInitialized() else { - parraLogWarn("Parra.registerDevicePushToken was called before Parra.initialize(). Make sure that you're calling Parra.initialize() before application.registerForRemoteNotifications()") + internal func registerDevicePushTokenString( + _ tokenString: String + ) async { + guard await state.isInitialized() else { + logger.warn("Parra.registerDevicePushToken was called before Parra.initialize(). Make sure that you're calling Parra.initialize() before application.registerForRemoteNotifications()") // Still try to recover from this situation. Temporarily cache the token, which we can check for when // initialization does occur and proceed to upload it. - await ParraGlobalState.shared.setTemporaryPushToken(tokenString) + await state.setTemporaryPushToken(tokenString) return } @@ -43,26 +51,25 @@ public extension Parra { await uploadDevicePushToken(tokenString) } - internal class func uploadDevicePushToken(_ token: String) async { + internal func uploadDevicePushToken(_ token: String) async { do { - try await Parra.API.Push.uploadPushToken(token: token) + try await networkManager.uploadPushToken(token: token) - parraLogTrace("Device push token successfully uploaded. Clearing cache.") + logger.trace("Device push token successfully uploaded. Clearing cache.") // Token shouldn't be cached when it isn't absolutely necessary to recover from errors. If a new token // is issued, it will replace the old one. - await ParraGlobalState.shared.clearTemporaryPushToken() + await state.clearTemporaryPushToken() } catch let error { - parraLogTrace("Device push token failed to upload. Caching token to try later.") + logger.trace("Device push token failed to upload. Caching token to try later.") // In the event that the upload failed, we'll cache the token. This will be overridden by any new token // on the next app launch, but in the event that we're able to retry the request later, we'll have this // one on hand. - await ParraGlobalState.shared.setTemporaryPushToken(token) + await state.setTemporaryPushToken(token) - parraLogError("Error uploading push token to Parra API", error) + logger.error("Error uploading push token to Parra API", error) } } - /// This method should be invoked from the UIApplication delegate method /// application(_:didFailToRegisterForRemoteNotificationsWithError:) and pass in its error parameter to indicate to /// the Parra API that push notification token regsitration has failed for this device. @@ -71,7 +78,13 @@ public extension Parra { /// unreachable for any reason, or if the app doesn’t have the proper code-signing entitlement. When a failure /// occurs, set a flag and try to register again at a later time. @MainActor - class func didFailToRegisterForRemoteNotifications(with error: Error) { - parraLogError("Failed to register for remote notifications", error) + static func didFailToRegisterForRemoteNotifications(with error: Error) { + Task { + await getSharedInstance().didFailToRegisterForRemoteNotifications(with: error) + } + } + + internal func didFailToRegisterForRemoteNotifications(with error: Error) async { + logger.error("Failed to register for remote notifications", error) } } diff --git a/Parra/Core/Parra+SyncEvents.swift b/Parra/Core/Parra+SyncEvents.swift new file mode 100644 index 000000000..b3aa303dc --- /dev/null +++ b/Parra/Core/Parra+SyncEvents.swift @@ -0,0 +1,305 @@ +// +// Parra+SyncEvents.swift +// Parra +// +// Created by Michael MacCallum on 3/5/22. +// + +import Foundation +import UIKit + +// TODO: Everything here that is updated via notification should be checked and reported in the params +// for significant events like app state change/session start/stop/etc. + +fileprivate let logger = Logger(category: "Sync Event Extensions") + +internal extension Parra { + private static var backgroundTaskId: UIBackgroundTaskIdentifier? + private static var hasStartedEventObservers = false + + private var notificationsToObserve: [(Notification.Name, Selector)] { + [ + (UIApplication.didBecomeActiveNotification, #selector(self.applicationDidBecomeActive)), + (UIApplication.willResignActiveNotification, #selector(self.applicationWillResignActive)), + (UIApplication.didEnterBackgroundNotification, #selector(self.applicationDidEnterBackground)), + (UIApplication.significantTimeChangeNotification, #selector(self.significantTimeChange)), + (UIApplication.didReceiveMemoryWarningNotification, #selector(self.didReceiveMemoryWarning)), + (UIApplication.userDidTakeScreenshotNotification, #selector(self.didTakeScreenshot)), + (UIDevice.orientationDidChangeNotification, #selector(self.orientationDidChange)), + (UIDevice.batteryLevelDidChangeNotification, #selector(self.batteryLevelChange)), + (UIDevice.batteryStateDidChangeNotification, #selector(self.batteryStateChange)), + (UIWindow.keyboardDidShowNotification, #selector(self.keyboardDidShow)), + (UIWindow.keyboardDidHideNotification, #selector(self.keyboardDidHide)), + // TODO: Need to access `ProcessInfo.thermalState` BEFORE registering this notification to receive updates. + (ProcessInfo.thermalStateDidChangeNotification, #selector(self.thermalStateDidChange)), + (.NSProcessInfoPowerStateDidChange, #selector(self.powerStateDidChange)), + (.NSBundleResourceRequestLowDiskSpace, #selector(self.didRequestLowDiskSpace)), + ] + } + + func addEventObservers() { + guard NSClassFromString("XCTestCase") == nil && !Parra.hasStartedEventObservers else { + return + } + + Parra.hasStartedEventObservers = true + + // TODO: Before adding observer, should we read the current values of everything and + // store them in user state? + + for (notificationName, selector) in notificationsToObserve { + addObserver(for: notificationName, selector: selector) + } + + // TODO: Not receiving these. Maybe just a simulator issue? + UIDevice.current.beginGeneratingDeviceOrientationNotifications() + UIDevice.current.isBatteryMonitoringEnabled = true + } + + func removeEventObservers() { + guard NSClassFromString("XCTestCase") == nil else { + return + } + + for (notificationName, _) in notificationsToObserve { + removeObserver(for: notificationName) + } + + UIDevice.current.endGeneratingDeviceOrientationNotifications() + UIDevice.current.isBatteryMonitoringEnabled = false + } + + @MainActor + @objc func applicationDidBecomeActive(notification: Notification) { + withInitializationCheck { [self] in + if let taskId = Parra.backgroundTaskId, + let app = notification.object as? UIApplication { + + app.endBackgroundTask(taskId) + } + + logEvent(.appStateChanged, [ + "state": UIApplication.State.active.loggerDescription + ]) + + triggerEventualSyncFromNotification(notification: notification) + } + } + + @MainActor + @objc func applicationWillResignActive(notification: Notification) { + withInitializationCheck { [self] in + logEvent(.appStateChanged, [ + "state": UIApplication.State.inactive.loggerDescription + ]) + + triggerSyncFromNotification(notification: notification) + + let endSession = { [self] in + guard let taskId = Parra.backgroundTaskId else { + return + } + + Parra.backgroundTaskId = nil + + logger.debug("Background task: \(taskId) triggering session end") + + await sessionManager.endSession() + + UIApplication.shared.endBackgroundTask(taskId) + } + + Parra.backgroundTaskId = UIApplication.shared.beginBackgroundTask( + withName: InternalConstants.backgroundTaskName + ) { + logger.debug("Background task expiration handler invoked") + + Task { @MainActor in + await endSession() + } + } + + let startTime = Date() + Task(priority: .background) { + while Date().timeIntervalSince(startTime) < InternalConstants.backgroundTaskDuration { + try await Task.sleep(for: 0.1) + } + + logger.debug("Ending Parra background execution after \(InternalConstants.backgroundTaskDuration)s") + + Task { @MainActor in + await endSession() + } + } + } + } + + @MainActor + @objc func applicationDidEnterBackground(notification: Notification) { + withInitializationCheck { [self] in + logEvent(.appStateChanged, [ + "state": UIApplication.State.background.loggerDescription + ]) + } + } + + @MainActor + @objc func significantTimeChange(notification: Notification) { + withInitializationCheck { [self] in + logEvent(.significantTimeChange) + + await syncManager.enqueueSync(with: .immediate) + } + } + + @MainActor + @objc func didReceiveMemoryWarning(notification: Notification) { + withInitializationCheck { [self] in + logEvent(.memoryWarning) + } + } + + @MainActor + @objc func didTakeScreenshot(notification: Notification) { + withInitializationCheck { [self] in + logEvent(.screenshotTaken) + } + } + + @MainActor + @objc func orientationDidChange(notification: Notification) { + withInitializationCheck { [self] in + logEvent(.orientationChanged, [ + "orientation": UIDevice.current.orientation.loggerDescription + ]) + } + } + + @MainActor + @objc func batteryLevelChange(notification: Notification) { + withInitializationCheck { [self] in + logEvent(.batteryLevelChanged, [ + "battery_level": UIDevice.current.batteryLevel + ]) + } + } + + @MainActor + @objc func batteryStateChange(notification: Notification) { + withInitializationCheck { [self] in + logEvent(.batteryStateChanged, [ + "battery_state": UIDevice.current.batteryState.loggerDescription + ]) + } + } + + @MainActor + @objc func keyboardDidShow(notification: Notification) { + withInitializationCheck { [self] in + logEvent(.keyboardDidShow, keyboardFrameParams(from: notification)) + } + } + + @MainActor + @objc func keyboardDidHide(notification: Notification) { + withInitializationCheck { [self] in + logEvent(.keyboardDidHide, keyboardFrameParams(from: notification)) + } + } + + @MainActor + @objc func thermalStateDidChange(notification: Notification) { + withInitializationCheck { [self] in + logEvent(.thermalStateChanged, [ + "thermal_state": ProcessInfo.processInfo.thermalState.loggerDescription + ]) + } + } + + @MainActor + @objc func powerStateDidChange(notification: Notification) { + withInitializationCheck { [self] in + logEvent(.powerStateChanged, [ + "power_state": ProcessInfo.processInfo.powerState.loggerDescription + ]) + } + } + + @MainActor + @objc func didRequestLowDiskSpace( + notification: Notification + ) { + withInitializationCheck { [self] in + let extra = URL.currentDiskUsage()?.sanitized.dictionary ?? [:] + logEvent(.diskSpaceLow, extra) + } + } + + @MainActor + @objc private func triggerSyncFromNotification( + notification: Notification + ) { + withInitializationCheck { [self] in + await syncManager.enqueueSync(with: .immediate) + } + } + + @MainActor + @objc private func triggerEventualSyncFromNotification( + notification: Notification + ) { + withInitializationCheck { [self] in + await syncManager.enqueueSync(with: .eventual) + } + } + + /// Prevent processing events if initialization hasn't occurred. + private func withInitializationCheck( + _ function: @escaping () async -> Void + ) { + Task { + guard await state.isInitialized() else { + return + } + + Task { @MainActor in + await function() + } + } + } + + private func addObserver( + for notificationName: Notification.Name, + selector: Selector + ) { + notificationCenter.addObserver( + self, + selector: #selector(self.triggerEventualSyncFromNotification), + name: UIApplication.significantTimeChangeNotification, + object: nil + ) + } + + private func removeObserver( + for notificationName: Notification.Name + ) { + notificationCenter.removeObserver( + self, + name: notificationName, + object: nil + ) + } + + private func keyboardFrameParams(from notification: Notification) -> [String : Any] { + var params = [String : Any]() + + if let value = notification.userInfo?[UIWindow.keyboardFrameBeginUserInfoKey] as? NSValue { + params["frame_begin"] = NSCoder.string(for: value.cgRectValue) + } + if let value = notification.userInfo?[UIWindow.keyboardFrameEndUserInfoKey] as? NSValue { + params["frame_end"] = NSCoder.string(for: value.cgRectValue) + } + + return params + } +} diff --git a/Parra/Core/Parra.swift b/Parra/Core/Parra.swift new file mode 100644 index 000000000..fb1b1e726 --- /dev/null +++ b/Parra/Core/Parra.swift @@ -0,0 +1,261 @@ +// +// Parra.swift +// Parra +// +// Created by Michael MacCallum on 11/22/21. +// + +import Foundation +import UIKit + +/// The primary module used to interact with the Parra SDK. +/// Call ``Parra/Parra/initialize(options:authProvider:)-8d8fx`` in your `AppDelegate.didFinishLaunchingWithOptions` +/// method to configure the SDK. +public class Parra: ParraModule, ParraModuleStateAccessor { + internal static private(set) var name = "Parra" + + internal let state: ParraState + internal let configState: ParraConfigState + + internal lazy var feedback: ParraFeedback = { + let parraFeedback = ParraFeedback( + parra: self, + dataManager: ParraFeedbackDataManager( + parra: self, + jsonEncoder: Parra.jsonCoding.jsonEncoder, + jsonDecoder: Parra.jsonCoding.jsonDecoder, + fileManager: Parra.fileManager + ) + ) + + Task { + await state.registerModule(module: parraFeedback) + } + + return parraFeedback + }() + + internal private(set) static var jsonCoding: ( + jsonEncoder: JSONEncoder, + jsonDecoder: JSONDecoder + ) = { + return ( + jsonEncoder: JSONEncoder.parraEncoder, + jsonDecoder: JSONDecoder.parraDecoder + ) + }() + + internal private(set) static var fileManager: FileManager = { + return .default + }() + + internal static var _shared: Parra! + + @usableFromInline + internal static func getSharedInstance() -> Parra { + if let _shared { + return _shared + } + + _shared = createParraInstance(with: .default) + + return _shared + } + + internal static func setSharedInstance(parra: Parra) { + _shared = parra + } + + internal let dataManager: ParraDataManager + internal let syncManager: ParraSyncManager + + @usableFromInline + internal let sessionManager: ParraSessionManager + internal let networkManager: ParraNetworkManager + internal let notificationCenter: NotificationCenterType + + internal init( + state: ParraState, + configState: ParraConfigState, + dataManager: ParraDataManager, + syncManager: ParraSyncManager, + sessionManager: ParraSessionManager, + networkManager: ParraNetworkManager, + notificationCenter: NotificationCenterType + ) { + self.state = state + self.configState = configState + self.dataManager = dataManager + self.syncManager = syncManager + self.sessionManager = sessionManager + self.networkManager = networkManager + self.notificationCenter = notificationCenter + + UIFont.registerFontsIfNeeded() // Needs to be called before any UI is displayed. + } + + deinit { + // This should only happen when the singleton is destroyed when the + // app is being killed, or during unit tests. + removeEventObservers() + } + + // MARK: - Authentication + + /// Used to clear any cached credentials for the current user. After calling logout, the authentication provider you configured + /// will be invoked the very next time the Parra API is accessed. + public static func logout(completion: (() -> Void)? = nil) { + getSharedInstance().logout(completion: completion) + } + + internal func logout(completion: (() -> Void)? = nil) { + Task { + await logout() + + DispatchQueue.main.async { + completion?() + } + } + } + + /// Used to clear any cached credentials for the current user. After calling logout, the authentication provider you configured + /// will be invoked the very next time the Parra API is accessed. + public static func logout() async { + await getSharedInstance().logout() + } + + internal func logout() async { + await syncManager.enqueueSync(with: .immediate) + await dataManager.updateCredential(credential: nil) + await syncManager.stopSyncTimer() + } + + // MARK: - Synchronization + + /// Uploads any cached Parra data. This includes data like answers to questions. + public static func triggerSync(completion: (() -> Void)? = nil) { + getSharedInstance().triggerSync(completion: completion) + } + + internal func triggerSync(completion: (() -> Void)? = nil) { + Task { + await triggerSync() + + completion?() + } + } + + /// Parra data is syncrhonized automatically. Use this method if you wish to trigger a synchronization event manually. + /// This may be something you want to do in response to a significant event in your app, or in response to a low memory + /// warning, for example. Note that in order to prevent excessive network activity it may take up to 30 seconds for the sync + /// to complete after being initiated. + public static func triggerSync() async { + await getSharedInstance().triggerSync() + } + + internal func triggerSync() async { + // Uploads any cached Parra data. This includes data like answers to questions. + // Don't expose sync mode publically. + await syncManager.enqueueSync(with: .eventual) + } + + internal func hasDataToSync(since date: Date?) async -> Bool { + return await sessionManager.hasDataToSync(since: date) + } + + internal func synchronizeData() async throws { + guard let response = try await sessionManager.synchronizeData() else { + return + } + + for module in await state.getAllRegisteredModules() { + module.didReceiveSessionResponse( + sessionResponse: response + ) + } + } + + // MARK: Configuration + + internal static func createParraInstance( + with configuration: ParraInstanceConfiguration + ) -> Parra { + let state = ParraState() + let configState = ParraConfigState() + let syncState = ParraSyncState() + + let credentialStorageModule = ParraStorageModule( + dataStorageMedium: .fileSystem( + baseUrl: configuration.storageConfiguration.baseDirectory, + folder: configuration.storageConfiguration.storageDirectoryName, + fileName: ParraDataManager.Key.userCredentialsKey, + storeItemsSeparately: false, + fileManager: fileManager + ), + jsonEncoder: configuration.storageConfiguration.sessionJsonEncoder, + jsonDecoder: configuration.storageConfiguration.sessionJsonDecoder + ) + + let sessionStorageUrl = configuration.storageConfiguration.baseDirectory + .appendDirectory(configuration.storageConfiguration.storageDirectoryName) + .appendDirectory("sessions") + + let notificationCenter = ParraNotificationCenter() + + let credentialStorage = CredentialStorage( + storageModule: credentialStorageModule + ) + + let sessionStorage = SessionStorage( + sessionReader: SessionReader( + basePath: sessionStorageUrl, + sessionJsonDecoder: .parraDecoder, + eventJsonDecoder: .spaceOptimizedDecoder, + fileManager: fileManager + ), + sessionJsonEncoder: .parraEncoder, + eventJsonEncoder: .spaceOptimizedEncoder + ) + + let dataManager = ParraDataManager( + baseDirectory: configuration.storageConfiguration.baseDirectory, + credentialStorage: credentialStorage, + sessionStorage: sessionStorage + ) + + let networkManager = ParraNetworkManager( + state: state, + configState: configState, + dataManager: dataManager, + urlSession: configuration.networkConfiguration.urlSession, + jsonEncoder: configuration.networkConfiguration.jsonEncoder, + jsonDecoder: configuration.networkConfiguration.jsonDecoder + ) + + let sessionManager = ParraSessionManager( + dataManager: dataManager, + networkManager: networkManager, + loggerOptions: ParraConfigState.defaultState.loggerOptions + ) + + let syncManager = ParraSyncManager( + state: state, + syncState: syncState, + networkManager: networkManager, + sessionManager: sessionManager, + notificationCenter: notificationCenter + ) + + Logger.loggerBackend = sessionManager + + return Parra( + state: state, + configState: configState, + dataManager: dataManager, + syncManager: syncManager, + sessionManager: sessionManager, + networkManager: networkManager, + notificationCenter: notificationCenter + ) + } +} diff --git a/Parra/ParraModule.swift b/Parra/Core/ParraModule.swift similarity index 90% rename from Parra/ParraModule.swift rename to Parra/Core/ParraModule.swift index 15ed24bef..a7ac6ff15 100644 --- a/Parra/ParraModule.swift +++ b/Parra/Core/ParraModule.swift @@ -29,10 +29,6 @@ internal extension ParraModule { dynamic static func libraryVersion() -> String { return bundle().infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" } - - static func persistentStorageFolder() -> String { - return name.lowercased() - } static func eventPrefix() -> String { return "parra:\(name.lowercased())" diff --git a/Parra/Documentation.docc/Documentation.md b/Parra/Documentation.docc/Documentation.md new file mode 100644 index 000000000..533ef0941 --- /dev/null +++ b/Parra/Documentation.docc/Documentation.md @@ -0,0 +1,13 @@ +# ``Parra`` + +Summary + +## Overview + +Text + +## Topics + +### Group + +- ``Symbol`` \ No newline at end of file diff --git a/Parra/Extensions/Date+now.swift b/Parra/Extensions/Date+now.swift new file mode 100644 index 000000000..59c1f42bd --- /dev/null +++ b/Parra/Extensions/Date+now.swift @@ -0,0 +1,15 @@ +// +// Date+now.swift +// Parra +// +// Created by Mick MacCallum on 7/3/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +extension Date { + static var now: Date { + return Date() + } +} diff --git a/Parra/Extensions/Encodable.swift b/Parra/Extensions/Encodable.swift deleted file mode 100644 index 4d191d9bd..000000000 --- a/Parra/Extensions/Encodable.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// Encodable.swift -// Parra -// -// Created by Mick MacCallum on 2/6/23. -// Copyright © 2023 Parra, Inc. All rights reserved. -// - -import Foundation - -extension Encodable { - func asDictionary() throws -> [String: Any] { - let data = try JSONEncoder.parraEncoder.encode(self) - - guard let dictionary = try JSONSerialization.jsonObject( - with: data, - options: .allowFragments - ) as? [String: Any] else { - throw ParraError.jsonError("Unable to convert object to JSON. Object: \(String(describing: self))") - } - - return dictionary - } -} - diff --git a/Parra/Extensions/FileManager+safePaths.swift b/Parra/Extensions/FileManager+safePaths.swift new file mode 100644 index 000000000..76ec9f91a --- /dev/null +++ b/Parra/Extensions/FileManager+safePaths.swift @@ -0,0 +1,109 @@ +// +// FileManager+safePaths.swift +// Parra +// +// Created by Michael MacCallum on 3/2/22. +// + +import Foundation + +fileprivate let logger = Logger(category: "Extensions") + +internal extension FileManager { + func safeCreateDirectory(at url: URL) throws { + try logger.withScope { logger in + let logName = url.lastComponents() + logger.trace("Checking if directory exists at: \(logName)") + + let (exists, isDirectory) = safeFileExists(at: url) + + if exists && !isDirectory { + throw ParraError.fileSystem( + path: url, + message: "Attempted to create a directory at a path that already contained a file." + ) + } + + if !exists { + logger.trace("Directory didn't exist at \(logName). Creating...") + try createDirectory(at: url, withIntermediateDirectories: true) + } else { + logger.trace("Directory already exists at \(logName)") + } + } + } + + func safeCreateFile( + at url: URL, + contents: Data? = nil, + overrideExisting: Bool = false, + attributes: [FileAttributeKey : Any]? = nil + ) throws { + try logger.withScope { logger in + let logName = url.lastComponents() + logger.trace("Checking if file exists at: \(logName)") + + let exists: Bool = try safeFileExists(at: url) + + if exists && !overrideExisting { + throw ParraError.fileSystem( + path: url, + message: "File already exists." + ) + } + + let success = createFile( + atPath: url.nonEncodedPath(), + contents: contents, + attributes: attributes + ) + + if !success { + throw ParraError.fileSystem( + path: url, + message: "File could not be created" + ) + } + } + } + + func safeFileExists(at url: URL) -> (exists: Bool, isDirectory: Bool) { + var isDirectory: ObjCBool = false + + let exists = fileExists( + atPath: url.nonEncodedPath(), + isDirectory: &isDirectory + ) + + return ( + exists: exists, + isDirectory: isDirectory.boolValue + ) + } + + func safeDirectoryExists(at url: URL) throws -> Bool { + let (exists, isDirectory) = safeFileExists(at: url) + + if exists && !isDirectory { + throw ParraError.fileSystem( + path: url, + message: "File exists at a path that is expected to be a directory." + ) + } + + return exists + } + + func safeFileExists(at url: URL) throws -> Bool { + let (exists, isDirectory) = safeFileExists(at: url) + + if exists && isDirectory { + throw ParraError.fileSystem( + path: url, + message: "Directory exists at at a path that is expected to be a file." + ) + } + + return exists + } +} diff --git a/Parra/Extensions/FileManager.swift b/Parra/Extensions/FileManager.swift deleted file mode 100644 index 3b50eca01..000000000 --- a/Parra/Extensions/FileManager.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// FileManager.swift -// Parra -// -// Created by Michael MacCallum on 3/2/22. -// - -import Foundation - -internal extension FileManager { - func safeCreateDirectory(at url: URL) throws { - let logName = url.pathComponents.suffix(3).joined(separator: "/") - - parraLogTrace("Checking if directory exists at: \(logName)") - var isDirectory: ObjCBool = false - let exists = fileExists( - atPath: url.path, - isDirectory: &isDirectory - ) - - if exists && !isDirectory.boolValue { - let error = NSError( - domain: "Parra", - code: 2139, - userInfo: [ - NSLocalizedDescriptionKey: "Tried to create a directory at a location that already contained a file.", - NSLocalizedFailureReasonErrorKey: "File existed at path: \(url.path)" - ] - ) - - parraLogError("Error: File existed at directory path \(logName)", error) - - throw error - } - - if !exists { - parraLogTrace("Directory didn't exist at \(logName). Creating...") - try createDirectory(at: url, withIntermediateDirectories: true) - } else { - parraLogTrace("Directory already exists at \(logName)") - } - } - - func safeFileExists(at url: URL) -> Bool { - if #available(iOS 16.0, *) { - return fileExists(atPath: url.path(percentEncoded: false)) - } else { - return fileExists(atPath: url.path) - } - } -} diff --git a/Parra/Extensions/HTTPURLResponse+ParraDictionaryConvertible.swift b/Parra/Extensions/HTTPURLResponse+ParraDictionaryConvertible.swift new file mode 100644 index 000000000..6e0265e92 --- /dev/null +++ b/Parra/Extensions/HTTPURLResponse+ParraDictionaryConvertible.swift @@ -0,0 +1,42 @@ +// +// HTTPURLResponse+ParraDictionaryConvertible.swift +// Parra +// +// Created by Mick MacCallum on 8/5/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +extension HTTPURLResponse: ParraSanitizedDictionaryConvertible { + var sanitized: ParraSanitizedDictionary { + var params: [String : Any] = [ + "status_code": statusCode, + "expected_content_length": expectedContentLength + ] + + if let url { + params["url"] = url.absoluteString + } + + if let mimeType { + params["mime_type"] = mimeType + } + + if let headers = allHeaderFields as? [String : String] { + params["headers"] = ParraDataSanitizer.sanitize( + httpHeaders: headers + ) + } + + if let suggestedFilename { + params["suggested_filename"] = suggestedFilename + } + + if let textEncodingName { + params["text_encoding_name"] = textEncodingName + } + + return ParraSanitizedDictionary(dictionary: params) + } +} diff --git a/Parra/Extensions/JSONDecoder.swift b/Parra/Extensions/JSONDecoder.swift index 8e0442fd9..01f020682 100644 --- a/Parra/Extensions/JSONDecoder.swift +++ b/Parra/Extensions/JSONDecoder.swift @@ -7,17 +7,22 @@ import Foundation -private let parraJsonDecoder: JSONDecoder = { - let decoder = JSONDecoder() - - decoder.keyDecodingStrategy = .convertFromSnakeCase - decoder.dateDecodingStrategy = .iso8601 +extension JSONDecoder { + internal private(set) static var parraDecoder: JSONDecoder = { + let decoder = JSONDecoder() - return decoder -}() + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .iso8601 -extension JSONDecoder { - static var parraDecoder: JSONDecoder { - return parraJsonDecoder - } + return decoder + }() + + internal private(set) static var spaceOptimizedDecoder: JSONDecoder = { + let decoder = JSONDecoder() + + decoder.dateDecodingStrategy = .iso8601 + decoder.keyDecodingStrategy = .convertFromSnakeCase + + return decoder + }() } diff --git a/Parra/Extensions/JSONEncoder.swift b/Parra/Extensions/JSONEncoder.swift index 82308c4ad..69848d53e 100644 --- a/Parra/Extensions/JSONEncoder.swift +++ b/Parra/Extensions/JSONEncoder.swift @@ -7,21 +7,58 @@ import Foundation -private let parraJsonEncoder: JSONEncoder = { - let encoder = JSONEncoder() - - encoder.keyEncodingStrategy = .convertToSnakeCase - encoder.dateEncodingStrategy = .iso8601 + +extension JSONEncoder { + internal private(set) static var parraEncoder: JSONEncoder = { + let encoder = JSONEncoder() + + encoder.keyEncodingStrategy = .convertToSnakeCase + encoder.dateEncodingStrategy = .iso8601 #if DEBUG - encoder.outputFormatting = .prettyPrinted + encoder.outputFormatting = .prettyPrinted #endif - - return encoder -}() -extension JSONEncoder { - static var parraEncoder: JSONEncoder { - return parraJsonEncoder - } + return encoder + }() + + // ! Important: can never use pretty printing. It is required by consumers + // of this encoder that JSON all be on the same line. + internal private(set) static var spaceOptimizedEncoder: JSONEncoder = { + let encoder = JSONEncoder() + + encoder.keyEncodingStrategy = .convertToSnakeCase + encoder.dateEncodingStrategy = .iso8601 + +#if DEBUG + // When debugging we may be looking at these lines in an events file manually + // and if they're sorted by key, each line will have its data in the same order. + encoder.outputFormatting = [.withoutEscapingSlashes, .sortedKeys] +#else + encoder.outputFormatting = [.withoutEscapingSlashes] +#endif + + return encoder + }() + + internal private(set) static var parraPrettyConsoleEncoder: JSONEncoder = { + let encoder = JSONEncoder() + + encoder.dateEncodingStrategy = .iso8601 + encoder.keyEncodingStrategy = .useDefaultKeys + encoder.dataEncodingStrategy = .custom({ _, encoder in + var container = encoder.singleValueContainer() + try container.encode("") + }) + encoder.nonConformingFloatEncodingStrategy = .convertToString( + positiveInfinity: "inf", + negativeInfinity: "-inf", + nan: "NaN" + ) + encoder.outputFormatting = [ + .withoutEscapingSlashes, .sortedKeys, .prettyPrinted + ] + + return encoder + }() } diff --git a/Parra/Extensions/Models.swift b/Parra/Extensions/Models.swift index f28461398..17acbc70a 100644 --- a/Parra/Extensions/Models.swift +++ b/Parra/Extensions/Models.swift @@ -8,7 +8,7 @@ import Foundation extension ParraCardItem { - public func getAllAssets() -> [Asset] { + internal func getAllAssets() -> [Asset] { switch self.data { case .question(let question): switch question.data { @@ -23,7 +23,7 @@ extension ParraCardItem { /// Cards that don't have a good mechanism for determining that the user is done making their selection. /// This determines which cards show the forward arrow button to manually commit their changes. - public var requiresManualNextSelection: Bool { + internal var requiresManualNextSelection: Bool { switch data { case .question(let question): switch question.kind { diff --git a/Parra/Extensions/NSURLRequest.CachePolicy+CustomStringConvertible.swift b/Parra/Extensions/NSURLRequest.CachePolicy+CustomStringConvertible.swift new file mode 100644 index 000000000..7ba235a62 --- /dev/null +++ b/Parra/Extensions/NSURLRequest.CachePolicy+CustomStringConvertible.swift @@ -0,0 +1,32 @@ +// +// NSURLRequest.CachePolicy+CustomStringConvertible.swift +// Parra +// +// Created by Mick MacCallum on 8/5/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +extension NSURLRequest.CachePolicy: CustomStringConvertible { + public var description: String { + switch self { + case .useProtocolCachePolicy: + return "use_protocol_cache_policy" + case .reloadIgnoringLocalCacheData: + return "reload_ignoring_local_cache_data" + case .reloadIgnoringLocalAndRemoteCacheData: + return "reload_ignoring_local_and_remote_cache_data" + case .reloadIgnoringCacheData: + return "reload_ignoring_cache_data" + case .returnCacheDataElseLoad: + return "return_cache_data_else_load" + case .returnCacheDataDontLoad: + return "return_cache_data_dont_load" + case .reloadRevalidatingCacheData: + return "reload_revalidating_cache_data" + @unknown default: + return "unknown" + } + } +} diff --git a/Parra/Extensions/Sequence.swift b/Parra/Extensions/Sequence+asyncMap.swift similarity index 93% rename from Parra/Extensions/Sequence.swift rename to Parra/Extensions/Sequence+asyncMap.swift index 7583a2e84..afff4bdce 100644 --- a/Parra/Extensions/Sequence.swift +++ b/Parra/Extensions/Sequence+asyncMap.swift @@ -1,5 +1,5 @@ // -// Sequence.swift +// Sequence+asyncMap.swift // Parra // // Created by Mick MacCallum on 10/15/22. diff --git a/Parra/Extensions/String+prefixes.swift b/Parra/Extensions/String+prefixes.swift new file mode 100644 index 000000000..6da28205f --- /dev/null +++ b/Parra/Extensions/String+prefixes.swift @@ -0,0 +1,18 @@ +// +// String+prefixes.swift +// Parra +// +// Created by Mick MacCallum on 9/16/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal extension String { + func hasAnyPrefix(_ prefixes: [String]) -> Bool { + // eventually this could be less naive + return prefixes.contains { prefix in + hasPrefix(prefix) + } + } +} diff --git a/Parra/Extensions/Task.swift b/Parra/Extensions/Task+sleep.swift similarity index 51% rename from Parra/Extensions/Task.swift rename to Parra/Extensions/Task+sleep.swift index 0a3e58621..3f9e7ecff 100644 --- a/Parra/Extensions/Task.swift +++ b/Parra/Extensions/Task+sleep.swift @@ -1,5 +1,5 @@ // -// Task.swift +// Task+sleep.swift // Parra // // Created by Mick MacCallum on 5/19/23. @@ -8,9 +8,14 @@ import Foundation -public extension Task where Success == Never, Failure == Never { +internal extension Task where Success == Never, Failure == Never { static func sleep(ms: Int) async throws { let duration = UInt64(ms * 1_000_000) try await Task.sleep(nanoseconds: duration) } + + static func sleep(for timeInterval: TimeInterval) async throws { + let duration = UInt64(timeInterval * 1_000_000_000) + try await Task.sleep(nanoseconds: duration) + } } diff --git a/Parra/Extensions/Thread.swift b/Parra/Extensions/Thread.swift deleted file mode 100644 index 4ac325b9b..000000000 --- a/Parra/Extensions/Thread.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// Thread.swift -// Parra -// -// Created by Mick MacCallum on 11/26/22. -// Copyright © 2022 Parra, Inc. All rights reserved. -// - -import Foundation - -extension Thread { - var threadName: String { - if isMainThread { - return "main" - } else if let threadName = Thread.current.name, !threadName.isEmpty { - return threadName - } else { - return description - } - } - - var threadId: Int { - return Int(pthread_mach_thread_np(pthread_self())) - } - - var queueName: String { - if let queueName = String(validatingUTF8: __dispatch_queue_get_label(nil)) { - return queueName - } else if let operationQueueName = OperationQueue.current?.name, !operationQueueName.isEmpty { - return operationQueueName - } else if let dispatchQueueName = OperationQueue.current?.underlyingQueue?.label, !dispatchQueueName.isEmpty { - return dispatchQueueName - } else { - return "n/a" - } - } -} diff --git a/Parra/Extensions/UIFont+registration.swift b/Parra/Extensions/UIFont+registration.swift new file mode 100644 index 000000000..e6ef60077 --- /dev/null +++ b/Parra/Extensions/UIFont+registration.swift @@ -0,0 +1,32 @@ +// +// UIFont+Extensions.swift +// Parra +// +// Created by Michael MacCallum on 12/31/21. +// + +import UIKit +import os + +extension UIFont { + private static let fontRegisteredLock = OSAllocatedUnfairLock(initialState: false) + + static func registerFontsIfNeeded() { + fontRegisteredLock.withLock { hasRegistered in + if hasRegistered { + return + } + + let bundle = Bundle(for: Parra.self) + guard let fontUrls = bundle.urls(forResourcesWithExtension: "ttf", subdirectory: nil) else { + return + } + + fontUrls.forEach { + CTFontManagerRegisterFontsForURL($0 as CFURL, .process, nil) + } + + hasRegistered = true + } + } +} diff --git a/Parra/Extensions/UIFont.swift b/Parra/Extensions/UIFont.swift deleted file mode 100644 index 1bcec6d58..000000000 --- a/Parra/Extensions/UIFont.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// UIFont+Extensions.swift -// Parra -// -// Created by Michael MacCallum on 12/31/21. -// - -import UIKit - -extension UIFont { - static var fontsRegistered = false - - static func registerFontsIfNeeded() { - if fontsRegistered { - return - } - - let bundle = Bundle(for: Parra.self) - guard let fontUrls = bundle.urls(forResourcesWithExtension: "ttf", subdirectory: nil) else { - return - } - - fontUrls.forEach { - CTFontManagerRegisterFontsForURL($0 as CFURL, .process, nil) - } - - fontsRegistered = true - } -} diff --git a/Parra/Extensions/UIViewController.swift b/Parra/Extensions/UIViewController.swift index d5a0005f8..07f0c8f57 100644 --- a/Parra/Extensions/UIViewController.swift +++ b/Parra/Extensions/UIViewController.swift @@ -8,7 +8,7 @@ import UIKit -public extension UIViewController { +internal extension UIViewController { static func topMostViewController() -> UIViewController? { return safeGetKeyWindow()?.topViewController() } diff --git a/Parra/Extensions/UIWindow.swift b/Parra/Extensions/UIWindow+topViewController.swift similarity index 94% rename from Parra/Extensions/UIWindow.swift rename to Parra/Extensions/UIWindow+topViewController.swift index 9be570634..a8e1b0b86 100644 --- a/Parra/Extensions/UIWindow.swift +++ b/Parra/Extensions/UIWindow+topViewController.swift @@ -1,5 +1,5 @@ // -// UIWindow.swift +// UIWindow+topViewController.swift // Parra // // Created by Mick MacCallum on 11/26/22. diff --git a/Parra/Extensions/URL+helpers.swift b/Parra/Extensions/URL+helpers.swift new file mode 100644 index 000000000..a2321d44b --- /dev/null +++ b/Parra/Extensions/URL+helpers.swift @@ -0,0 +1,15 @@ +// +// URL+helpers.swift +// Parra +// +// Created by Mick MacCallum on 8/27/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal extension URL { + func lastComponents(max: Int = 3) -> String { + return pathComponents.suffix(max).joined(separator: "/") + } +} diff --git a/Parra/Extensions/URL+safePaths.swift b/Parra/Extensions/URL+safePaths.swift new file mode 100644 index 000000000..5dfb50bae --- /dev/null +++ b/Parra/Extensions/URL+safePaths.swift @@ -0,0 +1,44 @@ +// +// URL+safePaths.swift +// Parra +// +// Created by Mick MacCallum on 12/30/22. +// Copyright © 2022 Parra, Inc. All rights reserved. +// + +import Foundation + +internal extension URL { + func appendDirectory(_ directory: String) -> URL { + return appending( + component: directory, + directoryHint: .isDirectory + ) + } + + func appendFilename(_ fileName: String) -> URL { + return appending( + component: fileName, + directoryHint: .notDirectory + ) + } + + func nonEncodedPath() -> String { + return path(percentEncoded: false) + } + + /// Should be used anywhere that a file paths from the target device may be stored and/or uploaded. + /// This is a sanitization technique to prevent data like usernames or other sensitive information that might + /// be in an absolute path from being stored. + func privateRelativePath() -> String { + guard isFileURL else { + return absoluteString + } + + let prefix = ParraDataManager.Base.homeUrl.nonEncodedPath() + let base = nonEncodedPath() + let path = String(base.trimmingPrefix(prefix)) + + return "~/\(path)" + } +} diff --git a/Parra/Extensions/URL.swift b/Parra/Extensions/URL.swift deleted file mode 100644 index 062554e64..000000000 --- a/Parra/Extensions/URL.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// URL.swift -// Parra -// -// Created by Mick MacCallum on 12/30/22. -// Copyright © 2022 Parra, Inc. All rights reserved. -// - -import Foundation - -internal extension URL { - func safeAppendDirectory(_ dir: String) -> URL { - if #available(iOS 16.0, *) { - return appending(path: dir, directoryHint: .isDirectory) - } else { - return appendingPathComponent(dir, isDirectory: true) - } - } - - func safeAppendPathComponent(_ pathComponent: String) -> URL { - if #available(iOS 16.0, *) { - return appending(component: pathComponent, directoryHint: .notDirectory) - } else { - return appendingPathComponent(pathComponent, isDirectory: false) - } - } - - func safeNonEncodedPath() -> String { - if #available(iOS 16.0, *) { - return path(percentEncoded: false) - } else { - return path - } - } -} diff --git a/Parra/Extensions/URLComponents.swift b/Parra/Extensions/URLComponents+queryItems.swift similarity index 72% rename from Parra/Extensions/URLComponents.swift rename to Parra/Extensions/URLComponents+queryItems.swift index 10f44c8f0..7df8cf546 100644 --- a/Parra/Extensions/URLComponents.swift +++ b/Parra/Extensions/URLComponents+queryItems.swift @@ -1,5 +1,5 @@ // -// URLComponents.swift +// URLComponents+queryItems.swift // Parra // // Created by Mick MacCallum on 9/18/22. @@ -9,7 +9,7 @@ import Foundation extension URLComponents { - mutating func setQueryItems(with parameters: [String: String]) { + mutating func setQueryItems(with parameters: [String : String]) { queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) } diff --git a/Parra/Extensions/URLRequest+ParraDictionaryConvertible.swift b/Parra/Extensions/URLRequest+ParraDictionaryConvertible.swift new file mode 100644 index 000000000..ea0a58fd2 --- /dev/null +++ b/Parra/Extensions/URLRequest+ParraDictionaryConvertible.swift @@ -0,0 +1,47 @@ +// +// URLRequest+ParraDictionaryConvertible.swift +// Parra +// +// Created by Mick MacCallum on 8/5/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +extension URLRequest: ParraSanitizedDictionaryConvertible { + var sanitized: ParraSanitizedDictionary { + var params: [String : Any] = [ + "timeout_interval": timeoutInterval, + "should_handle_cookies": httpShouldHandleCookies, + "should_use_pipelining": httpShouldUsePipelining, + "allows_cellular_access": allowsCellularAccess, + "allows_constrainted_network_access": allowsConstrainedNetworkAccess, + "allows_expensive_network_access": allowsExpensiveNetworkAccess, + "network_service_type": networkServiceType.rawValue, + "attribution": attribution.description, + "assumes_http3_capable": assumesHTTP3Capable, + "cache_policy": cachePolicy.description, + "requires_dns_sec_validation": requiresDNSSECValidation + ] + + if let httpMethod { + params["method"] = httpMethod + } + + if let url { + params["url"] = url.absoluteString + } + + if let httpBody { + params["body"] = String(data: httpBody, encoding: .utf8) ?? httpBody.base64EncodedString() + } + + if let allHTTPHeaderFields { + params["headers"] = ParraDataSanitizer.sanitize( + httpHeaders: allHTTPHeaderFields + ) + } + + return ParraSanitizedDictionary(dictionary: params) + } +} diff --git a/Parra/Extensions/URLRequest+headers.swift b/Parra/Extensions/URLRequest+headers.swift new file mode 100644 index 000000000..75358464f --- /dev/null +++ b/Parra/Extensions/URLRequest+headers.swift @@ -0,0 +1,23 @@ +// +// URLRequest+headers.swift +// Parra +// +// Created by Mick MacCallum on 8/29/22. +// Copyright © 2022 Parra, Inc. All rights reserved. +// + +import Foundation + +internal extension URLRequest { + mutating func setValue(for field: URLRequestHeaderField) { + setValue(field.value, forHTTPHeaderField: field.name) + } + + mutating func setValue(_ value: String?, forHTTPHeaderField field: ParraHeader) { + setValue(value, forHTTPHeaderField: field.prefixedName) + } + + mutating func setValue(for parraHeader: ParraHeader) { + setValue(parraHeader.currentValue, forHTTPHeaderField: parraHeader.prefixedName) + } +} diff --git a/Parra/Extensions/URLRequest.Attribution+CustomStringConvertible.swift b/Parra/Extensions/URLRequest.Attribution+CustomStringConvertible.swift new file mode 100644 index 000000000..2f6c92785 --- /dev/null +++ b/Parra/Extensions/URLRequest.Attribution+CustomStringConvertible.swift @@ -0,0 +1,22 @@ +// +// URLRequest.Attribution+CustomStringConvertible.swift +// Parra +// +// Created by Mick MacCallum on 8/6/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +extension URLRequest.Attribution: CustomStringConvertible { + public var description: String { + switch self { + case .developer: + return "developer" + case .user: + return "user" + @unknown default: + return "unknown" + } + } +} diff --git a/Parra/Extensions/URLRequest.swift b/Parra/Extensions/URLRequest.swift deleted file mode 100644 index c6a12e74b..000000000 --- a/Parra/Extensions/URLRequest.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// URLRequest.swift -// Parra -// -// Created by Mick MacCallum on 8/29/22. -// Copyright © 2022 Parra, Inc. All rights reserved. -// - -import Foundation - -internal enum URLRequestHeaderField: CustomStringConvertible { - case authorization - case accept - case contentType - - var description: String { - switch self { - case .authorization: - return "Authorization" - case .accept: - return "Accept" - case .contentType: - return "Content-Type" - } - } -} - -internal extension URLRequest { - mutating func setValue(_ value: String?, forHTTPHeaderField field: URLRequestHeaderField) { - setValue(value, forHTTPHeaderField: field.description) - } - - mutating func setValue(_ value: String?, forHTTPHeaderField field: ParraHeader) { - setValue(value, forHTTPHeaderField: field.prefixedName) - } - - mutating func setValue(for parraHeader: ParraHeader) { - setValue(parraHeader.currentValue, forHTTPHeaderField: parraHeader.prefixedName) - } -} diff --git a/Parra/Feedback/Extensions/NSAttributedString.swift b/Parra/Feedback/Extensions/NSAttributedString.swift index 5ae70fb7d..7bcb22342 100644 --- a/Parra/Feedback/Extensions/NSAttributedString.swift +++ b/Parra/Feedback/Extensions/NSAttributedString.swift @@ -23,7 +23,7 @@ extension NSAttributedString { var parraLogoAttributes = AttributeContainer() parraLogoAttributes[AttributeScopes.UIKitAttributes.FontAttribute.self] = - UIFont(name: "Pacifico-Regular", size: 11) ?? UIFont.boldSystemFont(ofSize: 11) + UIFont(name: "Pacifico-Regular", size: 11) ?? UIFont.boldSystemFont(ofSize: 11) // TODO: Delete let parraLogo = AttributedString("Parra", attributes: parraLogoAttributes) diff --git a/Parra/Feedback/Fixtures/FeedbackFormField+Fixtures.swift b/Parra/Feedback/Fixtures/FeedbackFormField+Fixtures.swift new file mode 100644 index 000000000..7de3b2b16 --- /dev/null +++ b/Parra/Feedback/Fixtures/FeedbackFormField+Fixtures.swift @@ -0,0 +1,56 @@ +// +// FeedbackFormField+Fixtures.swift +// Parra +// +// Created by Mick MacCallum on 1/18/24. +// Copyright © 2024 Parra, Inc. All rights reserved. +// + +import Foundation + +extension FeedbackFormField: ParraFixture { + static func validStates() -> [FeedbackFormField] { + return [ + FeedbackFormField( + name: "text-field", + title: "We'd love to hear your feedback!", + helperText: "fill this out", + type: .text, + required: true, + data: .feedbackFormTextFieldData( + FeedbackFormTextFieldData( + placeholder: "placeholder", + lines: 4, + maxLines: 69, + minCharacters: 20, + maxCharacters: 420, + maxHeight: 200 + ) + ) + ), + FeedbackFormField( + name: "select-field", + title: "Which one of these options is the best?", + helperText: "fill this out, please!", + type: .select, + required: true, + data: .feedbackFormSelectFieldData( + FeedbackFormSelectFieldData( + placeholder: "Please select an option", + options: [ + .init(title: "General feedback", value: "general", isOther: nil), + .init(title: "Bug report", value: "bug", isOther: nil), + .init(title: "Feature request", value: "feature", isOther: nil), + .init(title: "Idea", value: "idea", isOther: nil), + .init(title: "Other", value: "other", isOther: nil), + ] + ) + ) + ) + ] + } + + static func invalidStates() -> [FeedbackFormField] { + return [] + } +} diff --git a/Parra/Feedback/Fixtures/FeedbackFormSelectFieldData+Fixtures.swift b/Parra/Feedback/Fixtures/FeedbackFormSelectFieldData+Fixtures.swift new file mode 100644 index 000000000..d1bf1e4a1 --- /dev/null +++ b/Parra/Feedback/Fixtures/FeedbackFormSelectFieldData+Fixtures.swift @@ -0,0 +1,50 @@ +// +// FeedbackFormSelectFieldData+Fixtures.swift +// Parra +// +// Created by Mick MacCallum on 1/18/24. +// Copyright © 2024 Parra, Inc. All rights reserved. +// + +import Foundation + +extension FeedbackFormSelectFieldData: ParraFixture { + static func validStates() -> [FeedbackFormSelectFieldData] { + return [ + FeedbackFormSelectFieldData( + placeholder: "Please select an option", + options: [ + FeedbackFormSelectFieldOption( + title: "General feedback", + value: "general-feedback", + isOther: nil + ), + FeedbackFormSelectFieldOption( + title: "Bug report", + value: "bug-report", + isOther: nil + ), + FeedbackFormSelectFieldOption( + title: "Feature request", + value: "feature-request", + isOther: nil + ), + FeedbackFormSelectFieldOption( + title: "Idea", + value: "idea", + isOther: nil + ), + FeedbackFormSelectFieldOption( + title: "Other", + value: "other", + isOther: nil + ), + ] + ) + ] + } + + static func invalidStates() -> [FeedbackFormSelectFieldData] { + return [] + } +} diff --git a/Parra/Feedback/Fixtures/FeedbackFormTextFieldData+Fixtures.swift b/Parra/Feedback/Fixtures/FeedbackFormTextFieldData+Fixtures.swift new file mode 100644 index 000000000..dc3c65822 --- /dev/null +++ b/Parra/Feedback/Fixtures/FeedbackFormTextFieldData+Fixtures.swift @@ -0,0 +1,28 @@ +// +// FeedbackFormTextFieldData+Fixtures.swift +// Parra +// +// Created by Mick MacCallum on 1/18/24. +// Copyright © 2024 Parra, Inc. All rights reserved. +// + +import Foundation + +extension FeedbackFormTextFieldData: ParraFixture { + static func validStates() -> [FeedbackFormTextFieldData] { + return [ + FeedbackFormTextFieldData( + placeholder: "placeholder", + lines: 5, + maxLines: 10, + minCharacters: 20, + maxCharacters: 420, + maxHeight: 200 + ) + ] + } + + static func invalidStates() -> [FeedbackFormTextFieldData] { + return [] + } +} diff --git a/Parra/Feedback/Fixtures/FeedbackFormViewState+Fixtures.swift b/Parra/Feedback/Fixtures/FeedbackFormViewState+Fixtures.swift new file mode 100644 index 000000000..4ad1e724d --- /dev/null +++ b/Parra/Feedback/Fixtures/FeedbackFormViewState+Fixtures.swift @@ -0,0 +1,51 @@ +// +// FeedbackFormViewState+Fixtures.swift +// Parra +// +// Created by Mick MacCallum on 1/20/24. +// Copyright © 2024 Parra, Inc. All rights reserved. +// + +import Foundation + +extension FeedbackFormViewState: ParraFixture { + static func validStates() -> [FeedbackFormViewState] { + + return [ + FeedbackFormViewState( + formData: FeedbackFormData( + title: "Leave feedback", + description: "We'd love to hear from you. Your input helps us make our product better.", + fields: [ + .init( + name: "type", + title: "Type of Feedback", + helperText: nil, + type: .select, + required: true, + data: .feedbackFormSelectFieldData( + FeedbackFormSelectFieldData.validStates()[0] + ) + ), + .init( + name: "response", + title: "Your Feedback", + helperText: nil, + type: .text, + required: true, + data: .feedbackFormTextFieldData( + FeedbackFormTextFieldData.validStates()[0] + ) + ) + ] + ) + , + config: .default + ) + ] + } + + static func invalidStates() -> [FeedbackFormViewState] { + return [] + } +} diff --git a/Parra/Feedback/ParraCardAnswerHandler.swift b/Parra/Feedback/ParraCardAnswerHandler.swift index e009f9194..d3495a91d 100644 --- a/Parra/Feedback/ParraCardAnswerHandler.swift +++ b/Parra/Feedback/ParraCardAnswerHandler.swift @@ -13,7 +13,7 @@ import Foundation // 4. Must invoke dataManager.completeCard(completedCard) /// Maps a question id to an answer for that question -typealias AnswerStateMap = [String: QuestionAnswer] +typealias AnswerStateMap = [String : QuestionAnswer] internal protocol ParraQuestionHandlerDelegate: AnyObject { /// Not meant to be triggered during every selection event. Just when a new selection occurs that may be diff --git a/Parra/Feedback/ParraFeedback+Modals.swift b/Parra/Feedback/ParraFeedback+Modals.swift index 20b26280f..8ae1fb1d2 100644 --- a/Parra/Feedback/ParraFeedback+Modals.swift +++ b/Parra/Feedback/ParraFeedback+Modals.swift @@ -9,10 +9,12 @@ import Foundation import UIKit +fileprivate let logger = Logger(category: "Modals") + public extension ParraFeedback { // MARK: - Modals - static func presentCardPopup( + func presentCardPopup( with cards: [ParraCardItem], from fromViewController: UIViewController? = nil, config: ParraCardViewConfig = .default, @@ -20,7 +22,7 @@ public extension ParraFeedback { userDismissable: Bool = true, onDismiss: (() -> Void)? = nil ) { - parraLogInfo("Presenting card popup view controller with \(cards.count) card(s)") + logger.info("Presenting card popup view controller with \(cards.count) card(s)") let cardViewController = ParraCardPopupViewController( cards: cards, @@ -38,13 +40,13 @@ public extension ParraFeedback { ) } - static func presentCardDrawer( + func presentCardDrawer( with cards: [ParraCardItem], from fromViewController: UIViewController? = nil, config: ParraCardViewConfig = .drawerDefault, onDismiss: (() -> Void)? = nil ) { - parraLogInfo("Presenting drawer view controller with \(cards.count) card(s)") + logger.info("Presenting drawer view controller with \(cards.count) card(s)") let transitionStyle = ParraCardModalTransitionStyle.slide let cardViewController = ParraCardDrawerViewController( @@ -69,7 +71,7 @@ public extension ParraFeedback { // MARK: - Feedback Forms - static func presentFeedbackForm( + func presentFeedbackForm( with form: ParraFeedbackFormResponse, from fromViewController: UIViewController? = nil, config: ParraCardViewConfig = .drawerDefault @@ -94,12 +96,14 @@ public extension ParraFeedback { } // MARK: - Helpers - private static func presentModal(modal: UIViewController & ParraModal, - fromViewController: UIViewController?, - transitionStyle: ParraCardModalTransitionStyle, - config: ParraCardViewConfig) { + private func presentModal( + modal: UIViewController & ParraModal, + fromViewController: UIViewController?, + transitionStyle: ParraCardModalTransitionStyle, + config: ParraCardViewConfig + ) { guard let vc = fromViewController ?? UIViewController.topMostViewController() else { - parraLogWarn("Missing view controller to present popup from.") + logger.warn("Missing view controller to present popup from.") return } diff --git a/Parra/Feedback/ParraFeedback.swift b/Parra/Feedback/ParraFeedback.swift index 39794d9b4..60c2f52dc 100644 --- a/Parra/Feedback/ParraFeedback.swift +++ b/Parra/Feedback/ParraFeedback.swift @@ -7,34 +7,38 @@ import Foundation -fileprivate actor ParraFeedbackPopupState { - static let shared = ParraFeedbackPopupState() - - var isPresented = false - - func present() async { - isPresented = true - } - - func dismiss() async { - isPresented = false - } -} +fileprivate let logger = Logger(category: "Feedback module") /// The `ParraFeedback` module is used to fetch Parra Feedback data from the Parra API. Once data is fetched, /// it will be displayed automatically in any `ParraCardView`s that you add to your view hierarchy. /// To handle authentication, see the Parra module. public class ParraFeedback: ParraModule { - internal static let shared = ParraFeedback() - internal let dataManager = ParraFeedbackDataManager() + internal let parra: Parra + internal let dataManager: ParraFeedbackDataManager + + public static var shared: ParraFeedback { + return Parra.getSharedInstance().feedback + } + + internal init( + parra: Parra, + dataManager: ParraFeedbackDataManager + ) { + self.parra = parra + self.dataManager = dataManager + } internal private(set) static var name: String = "Feedback" + deinit { + parra.state.unregisterModule(module: self) + } + /// Fetch any available cards from the Parra API. Once cards are successfully fetched, they will automatically be cached by the `ParraFeedback` /// module and will be automatically displayed in `ParraCardView`s when they are added to your view hierarchy. The completion handler /// for this method contains a list of the card items that were recevied. If you'd like, you can filter them yourself and only pass select card items /// view the `ParraCardView` initializer if you'd like to only display certain cards. - public static func fetchFeedbackCards( + public func fetchFeedbackCards( appArea: ParraQuestionAppArea = .all, withCompletion completion: @escaping (Result<[ParraCardItem], ParraError>) -> Void ) { @@ -47,7 +51,7 @@ public class ParraFeedback: ParraModule { } } catch let error { DispatchQueue.main.async { - completion(.failure(ParraError.custom("Error fetching Parra Feedback cards", error))) + completion(.failure(ParraError.generic("Error fetching Parra Feedback cards", error))) } } } @@ -57,7 +61,7 @@ public class ParraFeedback: ParraModule { /// module and will be automatically displayed in `ParraCardView`s when they are added to your view hierarchy. The completion handler /// for this method contains a list of the card items that were recevied. If you'd like, you can filter them yourself and only pass select card items /// view the `ParraCardView` initializer if you'd like to only display certain cards. - public static func fetchFeedbackCards( + public func fetchFeedbackCards( appArea: ParraQuestionAppArea = .all, withCompletion completion: @escaping ([ParraCardItem], Error?) -> Void ) { @@ -83,10 +87,12 @@ public class ParraFeedback: ParraModule { /// module and will be automatically displayed in `ParraCardView`s when they are added to your view hierarchy. The completion handler /// for this method contains a list of the card items that were recevied. If you'd like, you can filter them yourself and only pass select card items /// view the `ParraCardView` initializer if you'd like to only display certain cards. - public static func fetchFeedbackCards( + public func fetchFeedbackCards( appArea: ParraQuestionAppArea = .all ) async throws -> [ParraCardItem] { - let cards = try await Parra.API.Feedback.getCards(appArea: appArea) + let cards = try await parra.networkManager.getCards( + appArea: appArea + ) // Only keep cards that we don't already have an answer cached for. This isn't something that // should ever even happen, but in event that new cards are retreived that include cards we @@ -95,12 +101,12 @@ public class ParraFeedback: ParraModule { var cardsToKeep = [ParraCardItem]() for card in cards { - if await shared.cachedCardPredicate(card: card) { + if await cachedCardPredicate(card: card) { cardsToKeep.append(card) } } - shared.applyNewCards(cards: cardsToKeep) + applyNewCards(cards: cardsToKeep) return cardsToKeep } @@ -108,24 +114,24 @@ public class ParraFeedback: ParraModule { /// Fetches the feedback form with the provided ID from the Parra API. If a form is returned, it is up to the caller /// to pass this response to `ParraFeedback.presentFeedbackForm` to present the feedback form. Splitting up feedback /// form presentation in this way allows us to skip having to show loading indicators. - public static func fetchFeedbackForm( + public func fetchFeedbackForm( formId: String ) async throws -> ParraFeedbackFormResponse { - let response = try await Parra.API.Feedback.getFeedbackForm(with: formId) - - return response + return try await parra.networkManager.getFeedbackForm(with: formId) } /// Fetches the feedback form with the provided ID from the Parra API. If a form is returned, it is up to the caller /// to pass this response to `ParraFeedback.presentFeedbackForm` to present the feedback form. Splitting up feedback /// form presentation in this way allows us to skip having to show loading indicators. - public static func fetchFeedbackForm( + public func fetchFeedbackForm( formId: String, withCompletion completion: @escaping (Result) -> Void ) { Task { do { - let response = try await fetchFeedbackForm(formId: formId) + let response = try await fetchFeedbackForm( + formId: formId + ) DispatchQueue.main.async { completion(.success(response)) @@ -133,7 +139,7 @@ public class ParraFeedback: ParraModule { } catch let error { DispatchQueue.main.async { completion( - .failure(ParraError.custom("Error fetching Parra Feedback form: \(formId)", error)) + .failure(ParraError.generic("Error fetching Parra Feedback form: \(formId)", error)) ) } } @@ -141,7 +147,7 @@ public class ParraFeedback: ParraModule { } /// Whether the `ParraFeedback` module has data that has yet to be synced with the Parra API. - internal func hasDataToSync() async -> Bool { + internal func hasDataToSync(since date: Date?) async -> Bool { let answers = await dataManager.currentCompletedCardData() return !answers.isEmpty @@ -159,8 +165,12 @@ public class ParraFeedback: ParraModule { } /// Checks whether the user has previously supplied input for the provided `ParraCardItem`. - internal class func hasCardBeenCompleted(_ cardItem: ParraCardItem) async -> Bool { - let completed = await shared.dataManager.completedCardData(forId: cardItem.id) + internal func hasCardBeenCompleted( + _ cardItem: ParraCardItem + ) async -> Bool { + let completed = await dataManager.completedCardData( + forId: cardItem.id + ) return completed != nil } @@ -170,15 +180,21 @@ public class ParraFeedback: ParraModule { let completedCardData = await dataManager.currentCompletedCardData() let completedCards = Array(completedCardData.values) - let completedChunks = completedCards.chunked(into: ParraFeedback.Constant.maxBulkAnswers) + let completedChunks = completedCards.chunked( + into: ParraFeedback.Constant.maxBulkAnswers + ) for chunk in completedChunks { do { try await self.uploadCompletedCards(chunk) - try await self.dataManager.clearCompletedCardData(completedCards: chunk) - await self.dataManager.removeCardsForCompletedCards(completedCards: chunk) + try await self.dataManager.clearCompletedCardData( + completedCards: chunk + ) + await self.dataManager.removeCardsForCompletedCards( + completedCards: chunk + ) } catch let error { - parraLogError(ParraError.custom("Error uploading card data", error)) + logger.error(ParraError.generic("Error uploading card data", error)) throw error } @@ -186,50 +202,71 @@ public class ParraFeedback: ParraModule { } private func uploadCompletedCards(_ cards: [CompletedCard]) async throws { - try await Parra.API.Feedback.bulkAnswerQuestions(cards: cards) + try await parra.networkManager.bulkAnswerQuestions( + cards: cards + ) } - private func performAssetPrefetch(for cards: [ParraCardItem]) { + private func performAssetPrefetch( + for cards: [ParraCardItem] + ) { + if cards.isEmpty { + return + } + Task { - parraLogDebug("Attempting asset prefetch for \(cards.count) card(s)...") + logger.debug("Attempting asset prefetch for \(cards.count) card(s)...") let assets = cards.flatMap { $0.getAllAssets() } - parraLogDebug("\(assets.count) asset(s) available for prefetching") + if assets.isEmpty { + logger.debug("No assets are available for prefetching") + + return + } - await Parra.API.Assets.performBulkAssetCachingRequest(assets: assets) + logger.debug("\(assets.count) asset(s) available for prefetching") - parraLogDebug("Completed prefetching assets") + await parra.networkManager.performBulkAssetCachingRequest( + assets: assets + ) + + logger.debug("Completed prefetching assets") } } - internal func didReceiveSessionResponse(sessionResponse: ParraSessionsResponse) { + internal func didReceiveSessionResponse( + sessionResponse: ParraSessionsResponse + ) { Task { let isPopupPresent = await ParraFeedbackPopupState.shared.isPresented + if isPopupPresent { - parraLogDebug("Skipping polling for questions. Popup currently open.") + logger.debug("Skipping polling for questions. Popup currently open.") } else { await pollForQuestions(context: sessionResponse) } } } - private func pollForQuestions(context: ParraSessionsResponse) async { - parraLogDebug("Checking if polling for questions should occur") + private func pollForQuestions( + context: ParraSessionsResponse + ) async { + logger.debug("Checking if polling for questions should occur") guard context.shouldPoll else { - parraLogTrace("Should poll flag not set, skipping polling") + logger.trace("Should poll flag not set, skipping polling") return } // Success means the request didn't fail and there are cards in the response that have a display type popup or drawer for attempt in 0...context.retryTimes { do { - parraLogTrace("Fetching cards. Attempt #\(attempt + 1)") + logger.trace("Fetching cards. Attempt #\(attempt + 1)") let cards = try await getCardsForPresentation() if cards.isEmpty { - parraLogTrace( + logger.trace( "No cards found. Retrying \(context.retryTimes - attempt) more time(s). Next attempt in \(context.retryDelay)ms" ) @@ -245,23 +282,25 @@ public class ParraFeedback: ParraModule { break } } catch let error { - parraLogError("Encountered error fetching cards. Cancelling polling.", error) + logger.error("Encountered error fetching cards. Cancelling polling.", error) } } } private func displayPopupCards(cardItems: [ParraCardItem]) { - parraLogTrace("Displaying popup cards") + logger.trace("Displaying popup cards") guard !cardItems.isEmpty else { - parraLogTrace("Skipping presenting popup. No valid cards") + logger.trace("Skipping presenting popup. No valid cards") return } // Take the display type of the first card that has one set as the display type to use for this set of cards. // It is unlikely there will ever be a case where this isn't found on the first element. - guard let displayType = cardItems.first(where: { $0.displayType != nil })?.displayType else { - parraLogTrace("Skipping presenting popup. No displayType set") + guard let displayType = cardItems.first( + where: { $0.displayType != nil } + )?.displayType else { + logger.trace("Skipping presenting popup. No displayType set") return } @@ -278,7 +317,7 @@ public class ParraFeedback: ParraModule { await ParraFeedbackPopupState.shared.present() } - ParraFeedback.presentCardPopup( + presentCardPopup( with: cardItems, from: nil, config: .default, @@ -289,12 +328,14 @@ public class ParraFeedback: ParraModule { onDismiss: onDismiss ) default: - parraLogTrace("Skipping presenting popup. displayType \(displayType) is not a valid modal type") + logger.trace("Skipping presenting popup. displayType \(displayType) is not a valid modal type") } } private func getCardsForPresentation() async throws -> [ParraCardItem] { - let cards = try await Parra.API.Feedback.getCards(appArea: .none) + let cards = try await parra.networkManager.getCards( + appArea: .none + ) var validCards = [ParraCardItem]() for card in cards { @@ -321,8 +362,13 @@ public class ParraFeedback: ParraModule { private func cachedCardPredicate(card: ParraCardItem) async -> Bool { switch card.data { case .question(let question): - let previouslyCleared = await dataManager.hasClearedCompletedCardWithId(card: card) - let cardData = await dataManager.completedCardData(forId: question.id) + let previouslyCleared = await dataManager.hasClearedCompletedCardWithId( + card: card + ) + + let cardData = await dataManager.completedCardData( + forId: question.id + ) if !previouslyCleared && cardData == nil { return true diff --git a/Parra/Feedback/ParraFeedbackDataManager.swift b/Parra/Feedback/ParraFeedbackDataManager.swift index c000a6be7..35ce73446 100644 --- a/Parra/Feedback/ParraFeedbackDataManager.swift +++ b/Parra/Feedback/ParraFeedbackDataManager.swift @@ -10,17 +10,29 @@ import Foundation internal class ParraFeedbackDataManager { private let completedCardDataStorage: CompletedCardDataStorage private let cardStorage: CardStorage + private let parra: Parra - internal init() { - let completedCardDataFolder = ParraFeedback.persistentStorageFolder() + internal init( + parra: Parra, + jsonEncoder: JSONEncoder, + jsonDecoder: JSONDecoder, + fileManager: FileManager + ) { + self.parra = parra + + let completedCardDataFolder = ParraDataManager.Directory.storageDirectoryName let completedCardDataFileName = ParraFeedbackDataManager.Key.completedCardsKey let completedCardDataStorageModule = ParraStorageModule( dataStorageMedium: .fileSystem( + baseUrl: parra.dataManager.baseDirectory, folder: completedCardDataFolder, fileName: completedCardDataFileName, - storeItemsSeparately: false - ) + storeItemsSeparately: false, + fileManager: fileManager + ), + jsonEncoder: jsonEncoder, + jsonDecoder: jsonDecoder ) self.completedCardDataStorage = CompletedCardDataStorage( @@ -28,7 +40,9 @@ internal class ParraFeedbackDataManager { ) let cardStorageModule = ParraStorageModule<[ParraCardItem]>( - dataStorageMedium: .memory + dataStorageMedium: .memory, + jsonEncoder: jsonEncoder, + jsonDecoder: jsonDecoder ) self.cardStorage = CardStorage( @@ -73,11 +87,11 @@ internal class ParraFeedbackDataManager { completedCards: completedCards ) - await Parra.triggerSync() + await parra.triggerSync() } } - internal func currentCompletedCardData() async -> [String: CompletedCard] { + internal func currentCompletedCardData() async -> [String : CompletedCard] { return await completedCardDataStorage.currentCompletedCardData() } @@ -109,15 +123,12 @@ internal class ParraFeedbackDataManager { internal func setCards(cards: [ParraCardItem]) { Task { await cardStorage.setCards(cardItems: cards) - - await MainActor.run { - DispatchQueue.main.async { - NotificationCenter.default.post( - name: ParraFeedback.cardsDidChangeNotification, - object: nil - ) - } - } + + await parra.notificationCenter.postAsync( + name: ParraFeedback.cardsDidChangeNotification, + object: nil, + userInfo: nil + ) } } } diff --git a/Parra/Feedback/Storage/CompletedCardDataStorage.swift b/Parra/Feedback/Storage/CompletedCardDataStorage.swift index 3839f4c0e..bf894963e 100644 --- a/Parra/Feedback/Storage/CompletedCardDataStorage.swift +++ b/Parra/Feedback/Storage/CompletedCardDataStorage.swift @@ -26,7 +26,7 @@ internal actor CompletedCardDataStorage: ItemStorage { return await storageModule.read(name: id) } - internal func currentCompletedCardData() async -> [String: CompletedCard] { + internal func currentCompletedCardData() async -> [String : CompletedCard] { return await storageModule.currentData() } @@ -37,7 +37,7 @@ internal actor CompletedCardDataStorage: ItemStorage { value: completedCard ) } catch let error { - parraLogError("Error writing completed card to cache", error) + Logger.error("Error writing completed card to cache", error) } } diff --git a/Parra/Feedback/Types/ParraFeedbackPopupState.swift b/Parra/Feedback/Types/ParraFeedbackPopupState.swift new file mode 100644 index 000000000..1a94bd3d9 --- /dev/null +++ b/Parra/Feedback/Types/ParraFeedbackPopupState.swift @@ -0,0 +1,23 @@ +// +// ParraFeedbackPopupState.swift +// Parra +// +// Created by Mick MacCallum on 7/2/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal actor ParraFeedbackPopupState { + static let shared = ParraFeedbackPopupState() + + var isPresented = false + + func present() async { + isPresented = true + } + + func dismiss() async { + isPresented = false + } +} diff --git a/Parra/Feedback/Utils/TextValidator.swift b/Parra/Feedback/Utils/TextValidator.swift index 31f581bfc..bd41bed17 100644 --- a/Parra/Feedback/Utils/TextValidator.swift +++ b/Parra/Feedback/Utils/TextValidator.swift @@ -31,12 +31,11 @@ struct TextValidator { /// is returned. If there is no error, nil is returned. This will only return a message for the first error /// that is encountered. static func validate( - text: String, - against rules: [TextValidatorRule], - requiredField: Bool + text: String?, + against rules: [TextValidatorRule] ) -> String? { - if requiredField && text.isEmpty { - return "response must not be empty" + guard let text, !text.isEmpty else { + return "must not be empty" } for rule in rules { diff --git a/Parra/Feedback/Utils/ViewTimer.swift b/Parra/Feedback/Utils/ViewTimer.swift index bf53a9484..3cced019a 100644 --- a/Parra/Feedback/Utils/ViewTimer.swift +++ b/Parra/Feedback/Utils/ViewTimer.swift @@ -35,12 +35,12 @@ fileprivate struct ViewTimerContext { internal extension ViewTimer { func performAfter(delay: TimeInterval, action: @escaping ViewTimerCallback) { - parraLogTrace("Delayed view action initiated with delay \(delay)") + Logger.trace("Delayed view action initiated with delay \(delay)") cancelTimer() let timer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in - parraLogTrace("Performing delayed view action") + Logger.trace("Performing delayed view action") ViewTimerContext.references.removeObject(forKey: self) action() @@ -51,7 +51,7 @@ internal extension ViewTimer { func cancelTimer() { if let existingTimer = ViewTimerContext.references.object(forKey: self) { - parraLogTrace("Cancelling existing delayed view action") + Logger.trace("Cancelling existing delayed view action") existingTimer.invalidate() } } diff --git a/Parra/Feedback/Views/Card Views/ParraCardItemView.swift b/Parra/Feedback/Views/Card Views/ParraCardItemView.swift index f1abc73ea..4c3c10291 100644 --- a/Parra/Feedback/Views/Card Views/ParraCardItemView.swift +++ b/Parra/Feedback/Views/Card Views/ParraCardItemView.swift @@ -8,8 +8,7 @@ import Foundation import UIKit -// TODO: Common ancestor. Might be able to just be a protocol. -public class ParraCardItemView: UIView, ParraConfigurableView { +internal class ParraCardItemView: UIView, ParraConfigurableView { internal var config: ParraCardViewConfig internal required init(config: ParraCardViewConfig) { diff --git a/Parra/Feedback/Views/Card Views/Question Kind Cards/ParraChoiceKindView.swift b/Parra/Feedback/Views/Card Views/Question Kind Cards/ParraChoiceKindView.swift index 7d9995233..e6303ab4e 100644 --- a/Parra/Feedback/Views/Card Views/Question Kind Cards/ParraChoiceKindView.swift +++ b/Parra/Feedback/Views/Card Views/Question Kind Cards/ParraChoiceKindView.swift @@ -91,7 +91,7 @@ internal class ParraChoiceKindView: UIView, ParraQuestionKindView { for option in data.options { guard let title = option.title else { - parraLogWarn("ParraChoiceKindView option: \(option.id) missing a title. Skipping.") + Logger.warn("ParraChoiceKindView option: \(option.id) missing a title. Skipping.") continue } diff --git a/Parra/Feedback/Views/Card Views/Question Kind Cards/ParraLongTextKindView.swift b/Parra/Feedback/Views/Card Views/Question Kind Cards/ParraLongTextKindView.swift index 826ff5997..09081bc1b 100644 --- a/Parra/Feedback/Views/Card Views/Question Kind Cards/ParraLongTextKindView.swift +++ b/Parra/Feedback/Views/Card Views/Question Kind Cards/ParraLongTextKindView.swift @@ -97,8 +97,7 @@ internal class ParraLongTextKindView: UIView, ParraQuestionKindView { private func updateValidation(text: String) { validationError = TextValidator.validate( text: text.trimmingCharacters(in: .whitespacesAndNewlines), - against: [.minLength(data.minLength), .maxLength(data.maxLength)], - requiredField: false + against: [.minLength(data.minLength), .maxLength(data.maxLength)] ) } } diff --git a/Parra/Feedback/Views/Misc/ParraImageButton.swift b/Parra/Feedback/Views/Misc/ParraImageButton.swift index 2fa41c4bc..963087385 100644 --- a/Parra/Feedback/Views/Misc/ParraImageButton.swift +++ b/Parra/Feedback/Views/Misc/ParraImageButton.swift @@ -64,13 +64,17 @@ internal class ParraImageButton: UIControl, SelectableButton, ParraConfigurableV ]) Task { - let isCached = await Parra.API.Assets.isAssetCached(asset: asset) + let isCached = await Parra.getSharedInstance().networkManager.isAssetCached( + asset: asset + ) if !isCached { self.activityIndicator.startAnimating() } - let image = try? await Parra.API.Assets.fetchAsset(asset: asset) + let image = try? await Parra.getSharedInstance().networkManager.fetchAsset( + asset: asset + ) Task { @MainActor in self.activityIndicator.stopAnimating() diff --git a/Parra/Feedback/Views/Modals/Feedback Forms/Fields/ParraFeedbackFormFieldView.swift b/Parra/Feedback/Views/Modals/Feedback Forms/Fields/ParraFeedbackFormFieldView.swift new file mode 100644 index 000000000..86bac5d8a --- /dev/null +++ b/Parra/Feedback/Views/Modals/Feedback Forms/Fields/ParraFeedbackFormFieldView.swift @@ -0,0 +1,23 @@ +// +// ParraFeedbackFormFieldView.swift +// ParraFeedback +// +// Created by Mick MacCallum on 6/11/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation +import SwiftUI + +internal struct FormFieldWithTypedData { + let data: DataType +} + +internal protocol ParraFeedbackFormFieldView: View { + associatedtype DataType: FeedbackFormFieldDataType + + var fieldWithState: FormFieldWithState { get set } + var fieldData: FormFieldWithTypedData { get set } + + var onFieldValueChanged: ((_ field: FeedbackFormField, _ value: String?) -> Void)? { get set } +} diff --git a/Parra/Feedback/Views/Modals/Feedback Forms/Fields/ParraFeedbackFormSelectFieldView.swift b/Parra/Feedback/Views/Modals/Feedback Forms/Fields/ParraFeedbackFormSelectFieldView.swift new file mode 100644 index 000000000..a25087c23 --- /dev/null +++ b/Parra/Feedback/Views/Modals/Feedback Forms/Fields/ParraFeedbackFormSelectFieldView.swift @@ -0,0 +1,95 @@ +// +// ParraFeedbackFormSelectFieldView.swift +// ParraFeedback +// +// Created by Mick MacCallum on 6/11/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation +import SwiftUI + +struct ParraFeedbackFormSelectFieldView: ParraFeedbackFormFieldView { + typealias DataType = FeedbackFormSelectFieldData + + var fieldWithState: FormFieldWithState + var fieldData: FormFieldWithTypedData + var onFieldValueChanged: ((FeedbackFormField, String?) -> Void)? + + @State private var selectedOption: FeedbackFormSelectFieldOption? + + private var elementOpacity: Double { + return selectedOption != nil ? 1.0 : 1 + } + + @ViewBuilder + private func menuItem(for index: Int) -> some View { + let option = fieldData.data.options[index] + + if option == selectedOption { + Button(option.title, systemImage: "checkmark") { + didSelect(fieldOption: option) + } + } else { + Button(option.title) { + didSelect(fieldOption: option) + } + } + } + + @ViewBuilder + private var menuLabel: some View { + let common = Text(selectedOption?.title ?? fieldData.data.placeholder ?? "Select an Option") + .font(.body) + .foregroundStyle(.primary.opacity(elementOpacity)) + .padding(.vertical, 18.5) + + if selectedOption == nil { + common + } else { + common + .fontWeight(.medium) + } + } + + var body: some View { + Menu { + ForEach(fieldData.data.options.indices, id: \.self) { index in + menuItem(for: index) + } + } label: { + HStack { + menuLabel + + Spacer() + + Image(systemName: "chevron.up.chevron.down") + .padding(.vertical, 16) + .foregroundStyle(.primary.opacity(elementOpacity)) + .frame(width: 24, height: 24) + } + } + .menuOrder(.fixed) + .tint(.primary.opacity(0.6)) + .padding(.horizontal, 14) + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(.gray, lineWidth: 1) + ) + } + + private func didSelect(fieldOption: FeedbackFormSelectFieldOption) { + selectedOption = fieldOption + + onFieldValueChanged?(fieldWithState.field, fieldOption.value) + } +} + +#Preview { + ParraFeedbackFormSelectFieldView.DataType.renderValidStates { fieldData in + ParraFeedbackFormSelectFieldView( + fieldWithState: .init(field: .init(name: "", title: "", helperText: "", type: .select, required: true, data: .feedbackFormSelectFieldData(fieldData))), + fieldData: .init(data: fieldData) + ) + } +} diff --git a/Parra/Feedback/Views/Modals/Feedback Forms/Fields/ParraFeedbackFormTextFieldView.swift b/Parra/Feedback/Views/Modals/Feedback Forms/Fields/ParraFeedbackFormTextFieldView.swift new file mode 100644 index 000000000..8648b08fd --- /dev/null +++ b/Parra/Feedback/Views/Modals/Feedback Forms/Fields/ParraFeedbackFormTextFieldView.swift @@ -0,0 +1,95 @@ +// +// ParraFeedbackFormTextFieldView.swift +// ParraFeedback +// +// Created by Mick MacCallum on 6/11/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation +import SwiftUI +import Combine + +fileprivate let minLines = 3 + +struct ParraFeedbackFormTextFieldView: ParraFeedbackFormFieldView { + typealias DataType = FeedbackFormTextFieldData + + var fieldWithState: FormFieldWithState + var fieldData: FormFieldWithTypedData + + var onFieldValueChanged: ((FeedbackFormField, String?) -> Void)? + + @State private var text = "" + @State private var hasReceivedInput = false + + private var maxLines: Int { + guard let maxLines = fieldData.data.maxLines else { + return 10 + } + + // Don't allow a max that is less than the min. + return max(minLines, maxLines) + } + + private var textEditor: some View { + TextEditor(text: $text) + .frame( + minHeight: 60, + idealHeight: 100, + maxHeight: CGFloat(fieldData.data.maxHeight ?? 240) + ) + .padding(6) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .lineLimit(minLines...maxLines) + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray, lineWidth: 1) + ) + .onChange(of: text) { _, newValue in + hasReceivedInput = true + onFieldValueChanged?(fieldWithState.field, newValue) + } + } + + @ViewBuilder + private var inputMessage: some View { + + switch fieldWithState.state { + case .valid: + let characterCount = text.count + + Text(fieldData.data.maxCharacters == nil + ? characterCount.simplePluralized(singularString: "character") + : "\(characterCount)/\(fieldData.data.maxCharacters!)" + ) + .foregroundColor(.secondary) + .padding(.trailing, 4) + case .invalid(let errorMessage): + Text(errorMessage) + .foregroundColor(hasReceivedInput ? .red : .gray) + .padding(.trailing, 4) + } + } + + var body: some View { + VStack(spacing: 5) { + textEditor + + HStack { + Spacer() + + inputMessage + } + } + } +} + +#Preview { + ParraFeedbackFormTextFieldView.DataType.renderValidStates { fieldData in + ParraFeedbackFormTextFieldView( + fieldWithState: .init(field: .init(name: "", title: "", helperText: "", type: .select, required: true, data: .feedbackFormTextFieldData(fieldData))), + fieldData: .init(data: fieldData) + ) + } +} diff --git a/Parra/Feedback/Views/Modals/Feedback Forms/ParraFeedbackFormView.swift b/Parra/Feedback/Views/Modals/Feedback Forms/ParraFeedbackFormView.swift new file mode 100644 index 000000000..eb828b7e3 --- /dev/null +++ b/Parra/Feedback/Views/Modals/Feedback Forms/ParraFeedbackFormView.swift @@ -0,0 +1,108 @@ +// +// ParraFeedbackFormView.swift +// ParraFeedback +// +// Created by Mick MacCallum on 6/6/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import SwiftUI +import Combine + + + +struct ParraFeedbackFormView: View { + @StateObject var viewModel: FeedbackFormViewState + + var header: some View { + VStack(alignment: .leading, spacing: 10) { + Text(viewModel.title) + .font(.largeTitle) + .fontWeight(.bold) + + if let description = viewModel.description { + Text(description) + .font(.subheadline).foregroundColor(.gray) + } + } + } + + var fieldViews: some View { + VStack(alignment: .leading, spacing: 20) { + ForEach(viewModel.fields) { fieldWithState in + let field = fieldWithState.field + + switch field.data { + case .feedbackFormSelectFieldData(let data): + ParraFeedbackFormSelectFieldView( + fieldWithState: fieldWithState, + fieldData: .init(data: data), + onFieldValueChanged: self.onFieldValueChanged + ) + case .feedbackFormTextFieldData(let data): + ParraFeedbackFormTextFieldView( + fieldWithState: fieldWithState, + fieldData: .init(data: data), + onFieldValueChanged: self.onFieldValueChanged + ) + case .feedbackFormInputFieldData(_): + // TODO: Support "input" type + EmptyView() + } + } + } + } + + var footer: some View { + VStack(spacing: 16) { + Button { + viewModel.submit() + } label: { + Text("Submit") + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity, minHeight: 50) + .background(viewModel.canSubmit ? .accentColor : Color.gray) + .cornerRadius(8) + } + .disabled(!viewModel.canSubmit) + + ParraLogoButton(type: .poweredBy) + } + } + + var body: some View { + NavigationView { + VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 20) { + header + + fieldViews + } + .layoutPriority(100) + + Spacer() + .layoutPriority(0) + + footer + } + .safeAreaPadding() + } + } + + private func onFieldValueChanged( + field: FeedbackFormField, + value: String? + ) { + viewModel.onFieldValueChanged( + field: field, + value: value + ) + } +} + +#Preview { + FeedbackFormViewState.renderValidStates { formViewState in + ParraFeedbackFormView(viewModel: formViewState) + } +} diff --git a/Parra/Feedback/Views/Modals/ParraFeedbackFormViewController.swift b/Parra/Feedback/Views/Modals/Feedback Forms/ParraFeedbackFormViewController.swift similarity index 72% rename from Parra/Feedback/Views/Modals/ParraFeedbackFormViewController.swift rename to Parra/Feedback/Views/Modals/Feedback Forms/ParraFeedbackFormViewController.swift index 16ca9eb30..fbd55894b 100644 --- a/Parra/Feedback/Views/Modals/ParraFeedbackFormViewController.swift +++ b/Parra/Feedback/Views/Modals/Feedback Forms/ParraFeedbackFormViewController.swift @@ -11,6 +11,8 @@ import Foundation import UIKit import SwiftUI +fileprivate let logger = Logger(category: "Feedback form") + internal class ParraFeedbackFormViewController: UIViewController, ParraModal { private let form: ParraFeedbackFormResponse @@ -25,10 +27,11 @@ internal class ParraFeedbackFormViewController: UIViewController, ParraModal { let formViewController = UIHostingController( rootView: ParraFeedbackFormView( - formId: form.id, - data: form.data, - config: config, - onSubmit: onSubmit + viewModel: FeedbackFormViewState( + formData: form.data, + config: config, + submissionHandler: self.onSubmit + ) ) ) @@ -45,10 +48,7 @@ internal class ParraFeedbackFormViewController: UIViewController, ParraModal { formViewController.didMove(toParent: self) - Parra.logAnalyticsEvent(ParraSessionEventType.impression( - location: "form", - module: ParraFeedback.self - ), params: [ + Parra.logEvent(.view(element: "feedback_form"), [ "formId": form.id ]) } @@ -62,12 +62,9 @@ internal class ParraFeedbackFormViewController: UIViewController, ParraModal { } private func onSubmit(data: [FeedbackFormField: String]) { - parraLogInfo("Submitting feedback form data") + logger.info("Submitting feedback form data") - Parra.logAnalyticsEvent(ParraSessionEventType.action( - source: "form-submit", - module: ParraFeedback.self - ), params: [ + Parra.logEvent(.submit(form: "feedback_form"), [ "formId": form.id ]) @@ -75,9 +72,12 @@ internal class ParraFeedbackFormViewController: UIViewController, ParraModal { Task { do { - try await Parra.API.Feedback.submitFeedbackForm(with: form.id, data: data) + try await Parra.getSharedInstance().networkManager.submitFeedbackForm( + with: form.id, + data: data + ) } catch let error { - parraLogError("Error submitting feedback form: \(form.id)", error) + logger.error("Error submitting feedback form: \(form.id)", error) } } } diff --git a/Parra/Feedback/Views/Modals/Feedback Forms/State/FeedbackFormViewState.swift b/Parra/Feedback/Views/Modals/Feedback Forms/State/FeedbackFormViewState.swift new file mode 100644 index 000000000..060a827dc --- /dev/null +++ b/Parra/Feedback/Views/Modals/Feedback Forms/State/FeedbackFormViewState.swift @@ -0,0 +1,105 @@ +// +// FeedbackFormViewState.swift +// Parra +// +// Created by Mick MacCallum on 1/19/24. +// Copyright © 2024 Parra, Inc. All rights reserved. +// + +import Foundation +import SwiftUI + +fileprivate let logger = Logger(category: "FeedbackFormViewState") + +@MainActor +final internal class FeedbackFormViewState: ObservableObject { + internal let title: String + internal let description: String? + + @Published + internal var fields: [FormFieldWithState] + + @Published + internal var canSubmit = false + + private let submissionHandler: (([FeedbackFormField : String]) -> Void)? + + internal init( + formData: FeedbackFormData, + config: ParraCardViewConfig, + submissionHandler: (([FeedbackFormField : String]) -> Void)? = nil + ) { + title = formData.title + description = formData.description + fields = formData.fields.map { FormFieldWithState(field: $0) } + + self.submissionHandler = submissionHandler + + // Additional call on init to handle the case where there none of the fields + // are marked as required, so their default state is valid. + canSubmit = FeedbackFormViewState.areAllFieldsValid( + fields: fields + ) + } + + internal func onFieldValueChanged( + field: FeedbackFormField, + value: String? + ) { + guard let updateIndex = fields.firstIndex( + where: { $0.id == field.id } + ) else { + logger.warn("Received event for update to unknown field", [ + "fieldId": field.id + ]) + + return + } + + var updatedFields = fields + var changedField = updatedFields[updateIndex] + changedField.updateValue(value) + updatedFields[updateIndex] = changedField + + fields = updatedFields + + canSubmit = FeedbackFormViewState.areAllFieldsValid( + fields: fields + ) + } + + internal func submit() { + let data = fields.reduce([FeedbackFormField : String]()) { accumulator, fieldWithState in + guard let value = fieldWithState.value else { + return accumulator + } + + var next = accumulator + next[fieldWithState.field] = value + return next + } + + submissionHandler?(data) + } + + private static func areAllFieldsValid( + fields: [FormFieldWithState] + ) -> Bool { + return fields.allSatisfy { fieldState in + let field = fieldState.field + let required = field.required ?? false + + // Don't count fields that aren't required. + guard required else { + return true + } + + switch fieldState.state { + case .valid: + return true + case .invalid: + return false + } + } + } +} diff --git a/Parra/Feedback/Views/Modals/Feedback Forms/State/FormFieldState.swift b/Parra/Feedback/Views/Modals/Feedback Forms/State/FormFieldState.swift new file mode 100644 index 000000000..894e658dd --- /dev/null +++ b/Parra/Feedback/Views/Modals/Feedback Forms/State/FormFieldState.swift @@ -0,0 +1,14 @@ +// +// FormFieldState.swift +// Parra +// +// Created by Mick MacCallum on 1/19/24. +// Copyright © 2024 Parra, Inc. All rights reserved. +// + +import Foundation + +internal enum FormFieldState { + case valid + case invalid(String) +} diff --git a/Parra/Feedback/Views/Modals/Feedback Forms/State/FormFieldWithState.swift b/Parra/Feedback/Views/Modals/Feedback Forms/State/FormFieldWithState.swift new file mode 100644 index 000000000..1daf70e47 --- /dev/null +++ b/Parra/Feedback/Views/Modals/Feedback Forms/State/FormFieldWithState.swift @@ -0,0 +1,71 @@ +// +// FormFieldWithState.swift +// Parra +// +// Created by Mick MacCallum on 1/19/24. +// Copyright © 2024 Parra, Inc. All rights reserved. +// + +import Foundation + +internal struct FormFieldWithState: Identifiable { + internal let field: FeedbackFormField + + internal private(set) var state: FormFieldState + internal private(set) var value: String? + + var id: String { + return field.id + } + + init( + field: FeedbackFormField + ) { + self.field = field + self.state = Self.validateUpdate(value: nil, for: field) + self.value = nil + } + + mutating func updateValue(_ value: String?) { + self.value = value + self.state = Self.validateUpdate(value: value, for: field) + } + + private static func validateUpdate( + value: String?, + for field: FeedbackFormField + ) -> FormFieldState { + switch field.data { + case .feedbackFormTextFieldData(let data): + var rules = [TextValidatorRule]() + + if let minCharacters = data.minCharacters { + rules.append(.minLength(minCharacters)) + } + + if let maxCharacters = data.maxCharacters { + rules.append(.maxLength(maxCharacters)) + } + + let errorMessage = TextValidator.validate( + text: value, + against: rules + ) + + return if let errorMessage { + .invalid(errorMessage) + } else { + .valid + } + case .feedbackFormSelectFieldData: + return if value == nil { + .invalid("No selection has been made.") + } else { + .valid + } + case .feedbackFormInputFieldData: + // TODO: Implement when support for input fields is added. + return .invalid("Input fields are not supported yet!") + } + } +} diff --git a/Parra/Feedback/Views/Modals/Fields/ParraFeedbackFormFieldView.swift b/Parra/Feedback/Views/Modals/Fields/ParraFeedbackFormFieldView.swift deleted file mode 100644 index 920b9659e..000000000 --- a/Parra/Feedback/Views/Modals/Fields/ParraFeedbackFormFieldView.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// ParraFeedbackFormFieldView.swift -// ParraFeedback -// -// Created by Mick MacCallum on 6/11/23. -// Copyright © 2023 Parra, Inc. All rights reserved. -// - -import Foundation -import SwiftUI - -typealias ParraFeedbackFormFieldUpdateHandler = (_ name: String, _ value: String?, _ valid: Bool) -> Void - -protocol ParraFeedbackFormFieldView: View { - associatedtype FieldData: FeedbackFormFieldDataType - - var formId: String { get set } - var field: FeedbackFormField { get set } - var fieldData: FieldData { get set } - var onFieldDataChanged: ParraFeedbackFormFieldUpdateHandler { get set } -} diff --git a/Parra/Feedback/Views/Modals/Fields/ParraFeedbackFormSelectFieldView.swift b/Parra/Feedback/Views/Modals/Fields/ParraFeedbackFormSelectFieldView.swift deleted file mode 100644 index 4e4161dea..000000000 --- a/Parra/Feedback/Views/Modals/Fields/ParraFeedbackFormSelectFieldView.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// ParraFeedbackFormSelectFieldView.swift -// ParraFeedback -// -// Created by Mick MacCallum on 6/11/23. -// Copyright © 2023 Parra, Inc. All rights reserved. -// - -import Foundation -import SwiftUI - -struct ParraFeedbackFormSelectFieldView: ParraFeedbackFormFieldView { - typealias FieldData = FeedbackFormSelectFieldData - - @State var formId: String - @State var field: FeedbackFormField - @State var fieldData: FieldData - @State var onFieldDataChanged: ParraFeedbackFormFieldUpdateHandler - - @State private var selectedType: String? - - var body: some View { - Picker(field.title ?? "", selection: $selectedType) { - // Only show the nil option if a selection hasn't been made yet. - if selectedType == nil { - Text(fieldData.placeholder ?? "Select an Option").tag(nil as String?) - } - - ForEach(fieldData.options) { option in - // Tag value must be wrapped in optional to continue to allow selection if a nil option is present. - Text(option.title).tag(Optional(option.value)) - } - } - .accentColor(.gray) - .pickerStyle(.menu) - .padding() - .frame(maxWidth: .infinity) - .background(.white) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .background( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.gray, lineWidth: 1) - ) - .onChange(of: selectedType) { newValue in - let valid = !(selectedType?.isEmpty ?? true) - onFieldDataChanged(field.name, newValue, valid) - } - } -} diff --git a/Parra/Feedback/Views/Modals/Fields/ParraFeedbackFormTextFieldView.swift b/Parra/Feedback/Views/Modals/Fields/ParraFeedbackFormTextFieldView.swift deleted file mode 100644 index a0062ace9..000000000 --- a/Parra/Feedback/Views/Modals/Fields/ParraFeedbackFormTextFieldView.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// ParraFeedbackFormTextFieldView.swift -// ParraFeedback -// -// Created by Mick MacCallum on 6/11/23. -// Copyright © 2023 Parra, Inc. All rights reserved. -// - -import Foundation -import SwiftUI -import Combine - -struct ParraFeedbackFormTextFieldView: ParraFeedbackFormFieldView { - typealias FieldData = FeedbackFormTextFieldData - - @State var formId: String - @State var field: FeedbackFormField - @State var fieldData: FieldData - @State var onFieldDataChanged: ParraFeedbackFormFieldUpdateHandler - - @State private var text = "" - @State private var hasReceivedInput = false - - var body: some View { - let errorMessage = validate( - text: text.trimmingCharacters(in: .whitespacesAndNewlines), - data: fieldData, - requiredField: field.required ?? false - ) - - VStack(spacing: 5) { - let textEditor = TextEditor(text: $text) - .frame(minHeight: 100, idealHeight: 100, maxHeight: 240) - .padding() - .background(.white) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .background( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.gray, lineWidth: 1) - ) - .onReceive(Just(text)) { newValue in - onFieldDataChanged(field.name, newValue, errorMessage == nil) - } - .onChange(of: text) { newValue in - hasReceivedInput = true - } - if #available(iOS 16.0, *) { - textEditor.lineLimit(3...) - } else { - textEditor - } - - HStack { - Spacer() - - if let errorMessage { - Text(errorMessage) - .foregroundColor(hasReceivedInput ? .red : .gray) - .padding(.trailing, 4) - } else { - let characterCount = text.count - - Text(fieldData.maxCharacters == nil - ? characterCount.simplePluralized(singularString: "character") - : "\(characterCount)/\(fieldData.maxCharacters!)" - ) - .foregroundColor(.secondary) - .padding(.trailing, 4) - } - } - } - } - - private func validate( - text: String, - data: FeedbackFormTextFieldData, - requiredField: Bool - ) -> String? { - var rules = [TextValidatorRule]() - - if let minCharacters = data.minCharacters { - rules.append(.minLength(minCharacters)) - } - - if let maxCharacters = data.maxCharacters { - rules.append(.maxLength(maxCharacters)) - } - - return TextValidator.validate( - text: text, - against: rules, - requiredField: requiredField - ) - } -} diff --git a/Parra/Feedback/Views/Modals/ParraCardModal.swift b/Parra/Feedback/Views/Modals/ParraCardModal.swift index 30e6df13c..278cf9799 100644 --- a/Parra/Feedback/Views/Modals/ParraCardModal.swift +++ b/Parra/Feedback/Views/Modals/ParraCardModal.swift @@ -8,8 +8,20 @@ import UIKit +internal struct ParraCardModalViewedEvent: ParraDataEvent { + var name: String + var extra: [String : Any] + + init(modalType: ParraCardModalType) { + self.name = modalType.eventName + self.extra = [ + "type": modalType.rawValue + ] + } +} + // raw values used in events -public enum ParraCardModalType: String { +internal enum ParraCardModalType: String { case popup case drawer @@ -17,11 +29,15 @@ public enum ParraCardModalType: String { var eventName: String { switch self { case .popup: - return "popup" + return "view:popup" case .drawer: - return "drawer" + return "view:drawer" } } + + var event: ParraDataEvent { + return ParraCardModalViewedEvent(modalType: self) + } } public enum ParraCardModalTransitionStyle { diff --git a/Parra/Feedback/Views/Modals/ParraCardModalViewController.swift b/Parra/Feedback/Views/Modals/ParraCardModalViewController.swift index a071872d2..10810e44f 100644 --- a/Parra/Feedback/Views/Modals/ParraCardModalViewController.swift +++ b/Parra/Feedback/Views/Modals/ParraCardModalViewController.swift @@ -30,12 +30,7 @@ internal class ParraCardModalViewController: UIViewController { // We count on the fact that we control when these modals are presented to know that // if one is instantiated, presentation is inevitable. This allows us to guarentee that // the impression event for the modal is submitted before the card view starts laying out cards. - Parra.logAnalyticsEvent(ParraSessionEventType.impression( - location: "modal:\(modalType.eventName)", - module: ParraFeedback.self - ), params: [ - "type": modalType.rawValue - ]) + Parra.logEvent(modalType.event) cardView = ParraCardView(config: config) diff --git a/Parra/Feedback/Views/Modals/ParraFeedbackFormView.swift b/Parra/Feedback/Views/Modals/ParraFeedbackFormView.swift deleted file mode 100644 index 720c19051..000000000 --- a/Parra/Feedback/Views/Modals/ParraFeedbackFormView.swift +++ /dev/null @@ -1,182 +0,0 @@ -// -// ParraFeedbackFormView.swift -// ParraFeedback -// -// Created by Mick MacCallum on 6/6/23. -// Copyright © 2023 Parra, Inc. All rights reserved. -// - -import SwiftUI -import Combine - -struct ParraFeedbackFormView: View { - @State var formId: String - @State var data: FeedbackFormData - @State var config: ParraCardViewConfig - @State var onSubmit: ([FeedbackFormField: String]) -> Void - - /// Maps form field names to their current values and whether they're currently considered valid. - @State private var formState = [String: (String, Bool)]() - - private var formFieldValues: [FeedbackFormField: String] { - return data.fields.reduce([FeedbackFormField: String]()) { partialResult, next in - guard let (value, _) = formState[next.name] else { - return partialResult - } - - var accumulator = partialResult - accumulator[next] = value - return accumulator - } - } - - // Submit enabled -> is the data for every field where "required" is set valid? - private var canSubmit: Bool { - return data.fields.reduce(true) { accumulator, field in - let required = field.required ?? false - - // Don't count fields that aren't required. - guard required else { - return accumulator - } - - guard let (_, valid) = formState[field.name] else { - if required { - return false - } - - return accumulator - } - - return accumulator && valid - } - } - - var body: some View { - VStack(alignment: .leading, spacing: 20) { - VStack(alignment: .leading, spacing: 10) { - Text(data.title) - .font(.largeTitle) - - if let description = data.description { - Text(description) - .font(.subheadline).foregroundColor(.gray) - } - } - - ForEach(data.fields) { (field: FeedbackFormField) in - switch field.data { - case .feedbackFormSelectFieldData(let data): - ParraFeedbackFormSelectFieldView( - formId: formId, - field: field, - fieldData: data, - onFieldDataChanged: self.onFieldDataChanged - ) - case .feedbackFormTextFieldData(let data): - ParraFeedbackFormTextFieldView( - formId: formId, - field: field, - fieldData: data, - onFieldDataChanged: self.onFieldDataChanged - ) - case .feedbackFormInputFieldData(_): - // TODO: Support "input" type - EmptyView() - } - } - - Spacer() - - Button { - onSubmit(formFieldValues) - } label: { - Text("Submit") - .font(.headline) - .foregroundColor(.white) - .frame(maxWidth: .infinity, minHeight: 50) - .background(canSubmit ? Color.blue : Color.gray) - .cornerRadius(8) - } - .disabled(!canSubmit) - - Button { - Parra.logAnalyticsEvent(ParraSessionEventType.action( - source: "powered-by-parra", - module: ParraFeedback.self - )) - - UIApplication.shared.open(Parra.Constants.parraWebRoot) - } label: { - Text(AttributedString(NSAttributedString.poweredByParra, including: \.uiKit)) - .foregroundColor(config.backgroundColor.isLight() - ? .black.opacity(0.1) - : .white.opacity(0.2)) - .frame(maxWidth: .infinity, alignment: .center) - } - } - .padding(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16)) - } - - private func onFieldDataChanged(_ name: String, _ value: String?, _ valid: Bool) { - if let value { - formState[name] = (value, valid) - } else { - formState.removeValue(forKey: name) - } - } -} - -struct ParraFeedbackFormView_Previews: PreviewProvider { - static var previews: some View { - VStack { - ParraFeedbackFormView( - formId: "a", - data: FeedbackFormData( - title: "Leave feedback", - description: "We'd love to hear from you. Your input helps us make our product better.", - fields: [ - .init( - name: "type", - title: "Type of Feedback", - helperText: nil, - type: .select, - required: true, - data: .feedbackFormSelectFieldData( - .init( - placeholder: "Please select an option", - options: [ - .init(title: "General feedback", value: "general", isOther: nil), - .init(title: "Bug report", value: "bug", isOther: nil), - .init(title: "Feature request", value: "feature", isOther: nil), - .init(title: "Idea", value: "idea", isOther: nil), - .init(title: "Other", value: "other", isOther: nil), - ] - ) - ) - ), - .init( - name: "response", - title: "Your Feedback", - helperText: nil, - type: .text, - required: true, - data: .feedbackFormTextFieldData( - .init( - placeholder: "Enter your feedback here...", - lines: nil, - maxLines: 3, - minCharacters: 12, - maxCharacters: 69, - maxHeight: nil - ) - ) - ) - ] - ), - config: ParraCardViewConfig.default, - onSubmit: { _ in } - ) - } - } -} diff --git a/Parra/Feedback/Views/ParraCardView/ParraCardCollectionViewCell.swift b/Parra/Feedback/Views/ParraCardView/ParraCardCollectionViewCell.swift index eaea5d0f0..e1aa60f50 100644 --- a/Parra/Feedback/Views/ParraCardView/ParraCardCollectionViewCell.swift +++ b/Parra/Feedback/Views/ParraCardView/ParraCardCollectionViewCell.swift @@ -61,7 +61,7 @@ public class ParraCardCollectionViewCell: UICollectionViewCell { /// } /// } public func endDisplaying() { - Parra.triggerSync() + Parra.getSharedInstance().triggerSync() } private func commonInit() { diff --git a/Parra/Feedback/Views/ParraCardView/ParraCardTableViewCell.swift b/Parra/Feedback/Views/ParraCardView/ParraCardTableViewCell.swift index 954ab0793..4621e7c91 100644 --- a/Parra/Feedback/Views/ParraCardView/ParraCardTableViewCell.swift +++ b/Parra/Feedback/Views/ParraCardView/ParraCardTableViewCell.swift @@ -34,7 +34,7 @@ public class ParraCardTableViewCell: UITableViewCell { parraCardView.config = config } } - + private let parraCardView = ParraCardView(config: .default) override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -68,7 +68,7 @@ public class ParraCardTableViewCell: UITableViewCell { /// } /// } public func endDisplaying() { - Parra.triggerSync() + Parra.getSharedInstance().triggerSync() } private func commonInit() { diff --git a/Parra/Feedback/Views/ParraCardView/ParraCardView+Layout.swift b/Parra/Feedback/Views/ParraCardView/ParraCardView+Layout.swift index 3360acecf..d3d25c9f0 100644 --- a/Parra/Feedback/Views/ParraCardView/ParraCardView+Layout.swift +++ b/Parra/Feedback/Views/ParraCardView/ParraCardView+Layout.swift @@ -186,10 +186,7 @@ extension ParraCardView { } @objc private func openParraLink() { - Parra.logAnalyticsEvent(ParraSessionEventType.action( - source: "powered-by-parra", - module: ParraFeedback.self - )) + Parra.logEvent(.tap(element: "powered-by-parra")) UIApplication.shared.open(Parra.Constants.parraWebRoot) } diff --git a/Parra/Feedback/Views/ParraCardView/ParraCardView+Transitions.swift b/Parra/Feedback/Views/ParraCardView/ParraCardView+Transitions.swift index db2001e6f..ad73b1a4e 100644 --- a/Parra/Feedback/Views/ParraCardView/ParraCardView+Transitions.swift +++ b/Parra/Feedback/Views/ParraCardView/ParraCardView+Transitions.swift @@ -9,7 +9,7 @@ import UIKit extension ParraCardView: ParraQuestionHandlerDelegate { internal func questionHandlerDidMakeNewSelection(forQuestion question: Question) async { - try? await Task.sleep(nanoseconds: 333_000_000) + try? await Task.sleep(for: 0.333) let (nextCardItem, nextCardItemDiection) = nextCardItem(inDirection: .right) @@ -17,7 +17,7 @@ extension ParraCardView: ParraQuestionHandlerDelegate { return } - guard !(await ParraFeedback.hasCardBeenCompleted(nextCardItem)) else { + guard !(await ParraFeedback.shared.hasCardBeenCompleted(nextCardItem)) else { return } @@ -184,10 +184,7 @@ extension ParraCardView { self.delegate?.parraCardView(self, didDisplay: cardItem) if let cardItem { - Parra.logAnalyticsEvent(ParraSessionEventType.impression( - location: "question", - module: ParraFeedback.self - ), params: [ + Parra.logEvent(.view(element: "question"), [ "question_id": cardItem.id ]) } diff --git a/Parra/Feedback/Views/ParraCardView/ParraCardView.swift b/Parra/Feedback/Views/ParraCardView/ParraCardView.swift index 360e82463..445078914 100644 --- a/Parra/Feedback/Views/ParraCardView/ParraCardView.swift +++ b/Parra/Feedback/Views/ParraCardView/ParraCardView.swift @@ -253,20 +253,22 @@ public class ParraCardView: UIView { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + deinit { - Parra.triggerSync() + Parra.getSharedInstance().triggerSync(completion: nil) } public override func willMove(toWindow newWindow: UIWindow?) { super.willMove(toWindow: newWindow) if newWindow == nil { - NotificationCenter.default.removeObserver(self, - name: ParraFeedback.cardsDidChangeNotification, - object: nil) + Parra.getSharedInstance().notificationCenter.removeObserver( + self, + name: ParraFeedback.cardsDidChangeNotification, + object: nil + ) - Parra.triggerSync() + Parra.getSharedInstance().triggerSync(completion: nil) } else { checkAndUpdateCards() } @@ -274,11 +276,13 @@ public class ParraCardView: UIView { public override func didMoveToWindow() { super.didMoveToWindow() - - NotificationCenter.default.addObserver(self, - selector: #selector(didReceiveCardChangeNotification(notification:)), - name: ParraFeedback.cardsDidChangeNotification, - object: nil) + + Parra.getSharedInstance().notificationCenter.addObserver( + self, + selector: #selector(didReceiveCardChangeNotification(notification:)), + name: ParraFeedback.cardsDidChangeNotification, + object: nil + ) } @objc private func didReceiveCardChangeNotification(notification: Notification) { diff --git a/Parra/Logger/ParraDefaultLogger.swift b/Parra/Logger/ParraDefaultLogger.swift deleted file mode 100644 index 859a0ee93..000000000 --- a/Parra/Logger/ParraDefaultLogger.swift +++ /dev/null @@ -1,213 +0,0 @@ -// -// ParraDefaultLogger.swift -// Parra -// -// Created by Mick MacCallum on 2/18/23. -// Copyright © 2023 Parra, Inc. All rights reserved. -// - -import Foundation - -internal class ParraDefaultLogger: ParraLogger { - static let `default` = ParraDefaultLogger() - static let timestampFormatter = ISO8601DateFormatter() - - var loggerConfig: ParraLoggerConfig = .default - - internal static let logQueue = DispatchQueue( - label: "com.parra.default-logger", - qos: .utility - ) - - func log( - level: ParraLogLevel, - message: ParraWrappedLogMessage, - extraError: Error?, - extra: [String : Any]?, - fileID: String, - function: String, - line: Int - ) { - // A serial queue needs to be used to process log events in order to remove races and keep them in order. - // This also means that we need to capture thread information before the context switch. - - let currentThread = Thread.current - var callStackSymbols: [String]? - if level == .fatal { - callStackSymbols = Thread.callStackSymbols - } - - ParraDefaultLogger.logQueue.async { - self.processLog( - level: level, - message: message, - extraError: extraError, - extra: extra, - fileID: fileID, - function: function, - line: line, - currentThread: currentThread, - callStackSymbols: callStackSymbols - ) - } - } - - private func processLog( - level: ParraLogLevel, - message: ParraWrappedLogMessage, - extraError: Error?, - extra: [String : Any]?, - fileID: String, - function: String, - line: Int, - currentThread: Thread, - callStackSymbols: [String]? - ) { - guard level.isAllowed else { - return - } - - let baseMessage: String - switch message { - case .string(let messageProvider): - baseMessage = messageProvider() - case .error(let errorProvider): - baseMessage = extractMessage(from: errorProvider()) - } - - let timestamp = ParraDefaultLogger.timestampFormatter.string(from: Date()) - let queue = currentThread.queueName - let (module, file) = splitFileId(fileId: fileID) - var extraWithAdditions = extra ?? [:] - if let extraError { - extraWithAdditions["errorDescription"] = extractMessage(from: extraError) - } - -#if DEBUG - var markerComponents: [String] = [] - - if loggerConfig.printTimestamps { - markerComponents.append(timestamp) - } - - if loggerConfig.printVerbosity { - markerComponents.append(level.name) - } - - if loggerConfig.printModuleName { - markerComponents.append(module) - } - - if loggerConfig.printFileName { - markerComponents.append(file) - } - - if loggerConfig.printThread { - markerComponents.append("🧵 \(queue)") - } - - let formattedMarkers = markerComponents.map { "[\($0)]" }.joined() - - var formattedMessage: String - if loggerConfig.printVerbositySymbol { - formattedMessage = " \(level.symbol) \(formattedMarkers) \(baseMessage)" - } else { - formattedMessage = "\(formattedMarkers) \(baseMessage)" - } - - if loggerConfig.printCallsite { - let formattedLocation = createFormattedLocation(fileID: fileID, function: function, line: line) - formattedMessage = "\(formattedMarkers) \(formattedLocation) \(baseMessage)" - } - - if !extraWithAdditions.isEmpty { - formattedMessage.append(contentsOf: " \(extraWithAdditions.debugDescription)") - } - - if let callStackSymbols { - let formattedStackTrace = callStackSymbols.joined(separator: "\n") - - formattedMessage.append(" - Call stack:\n\(formattedStackTrace)") - } - - print(formattedMessage) -#else - var params = [String: Any]() - params[ParraLoggerConfig.Constant.logEventMessageKey] = baseMessage - params[ParraLoggerConfig.Constant.logEventLevelKey] = level.name - params[ParraLoggerConfig.Constant.logEventTimestampKey] = timestamp - params[ParraLoggerConfig.Constant.logEventFileKey] = file - params[ParraLoggerConfig.Constant.logEventModuleKey] = module - params[ParraLoggerConfig.Constant.logEventThreadKey] = queue - params[ParraLoggerConfig.Constant.logEventThreadIdKey] = String(Thread.current.threadId) - if !extraWithAdditions.isEmpty { - params[ParraLoggerConfig.Constant.logEventExtraKey] = extraWithAdditions - } - if level >= .error { - params[ParraLoggerConfig.Constant.logEventCallStackKey] = Thread.callStackSymbols - } - - // TODO: Would capturing other info from ProcessInfo.processInfo.environment help with symbolication? - - Parra.logAnalyticsEvent(ParraSessionEventType._Internal.log, params: params) -#endif - } - - private func extractMessage(from error: Error) -> String { - if let parraError = error as? ParraError { - return parraError.errorDescription - } else { - // Error is always bridged to NSError, can't downcast to check. - if type(of: error) is NSError.Type { - let nsError = error as NSError - - return "Error domain: \(nsError.domain), code: \(nsError.code), description: \(nsError.localizedDescription)" - } else { - return String(reflecting: error) - } - } - } - - private func splitFileId(fileId: String) -> (module: String, fileName: String) { - let initialSplit = { - let parts = fileId.split(separator: "/") - - if parts.count == 0 { - return ("Unknown", "Unknown") - } else if parts.count == 1 { - return ("Unknown", String(parts[0])) - } else if parts.count == 2 { - return (String(parts[0]), String(parts[1])) - } else { - return (String(parts[0]), parts.dropFirst(1).joined(separator: "/")) - } - } - - let (module, fileName) = initialSplit() - - let fileParts = fileName.split(separator: ".") - if fileParts.count == 1 { - return (module, fileName) - } else { - return (module, fileParts.dropLast(1).joined(separator: ".")) - } - } - - private func createFormattedLocation(fileID: String, function: String, line: Int) -> String { - let file: String - if let extIndex = fileID.lastIndex(of: ".") { - file = String(fileID[.. Bool { - return lhs.rawValue < rhs.rawValue - } - - internal private(set) static var minAllowedLogLevel: ParraLogLevel = .default - - internal static func setMinAllowedLogLevel(_ minLevel: ParraLogLevel) { -#if DEBUG - if let rawLevelOverride = ProcessInfo.processInfo.environment[ParraLoggerConfig.Environment.allowedLogLevelOverrideKey], - let level = ParraLogLevel(name: rawLevelOverride) { - - minAllowedLogLevel = level - } else { - minAllowedLogLevel = minLevel - } -#else - minAllowedLogLevel = minLevel -#endif - } - - var isAllowed: Bool { - return self >= ParraLogLevel.minAllowedLogLevel - } - - var name: String { - switch self { - case .trace: - return "TRACE" - case .debug: - return "DEBUG" - case .info: - return "INFO" - case .warn: - return "WARN" - case .error: - return "ERROR" - case .fatal: - return "FATAL" - } - } - - var symbol: String { - switch self { - case .trace: - return "🟣" - case .debug: - return "🔵" - case .info: - return "⚪" - case .warn: - return "🟡" - case .error: - return "🔴" - case .fatal: - return "💀" - } - } -} diff --git a/Parra/Logger/ParraLogger.swift b/Parra/Logger/ParraLogger.swift deleted file mode 100644 index 2997c67fe..000000000 --- a/Parra/Logger/ParraLogger.swift +++ /dev/null @@ -1,230 +0,0 @@ -// -// ParraLogger.swift -// Parra -// -// Created by Michael MacCallum on 3/5/22. -// - -import Foundation - -public protocol ParraLogger: AnyObject { - func log( - level: ParraLogLevel, - // Messages should always be functions that return a message. This allows the logger to only execute potentially - // expensive code if the logger is enabled. A wrapper object is provided to help differentiate between log types. - message: ParraWrappedLogMessage, - // When a primary message is provided but there is still an error object attached to the log. - extraError: Error?, - extra: [String: Any]?, - // It is expected that #fileID is passed here for module resolution to function properly - fileID: String, - function: String, - line: Int - ) -} - -fileprivate func _parraLog( - message: ParraWrappedLogMessage, - extraError: Error? = nil, - extra: [String: Any] = [:], - level: ParraLogLevel, - fileID: String, - function: String, - line: Int -) { - ParraDefaultLogger.default.log( - level: level, - message: message, - extraError: extraError, - extra: extra, - fileID: fileID, - function: function, - line: line - ) -} - -// TODO: Wrap extra in its own autoclosure - -public func parraLog(_ message: @autoclosure @escaping () -> String, - extra: [String: Any] = [:], - level: ParraLogLevel = .info, - fileID: String = #fileID, - function: String = #function, - line: Int = #line) { - _parraLog( - message: .string(message), - extra: extra, - level: level, - fileID: fileID, - function: function, - line: line - ) -} - -public func parraLogTrace(_ message: @autoclosure @escaping () -> String, - _ extra: [String: Any] = [:], - fileID: String = #fileID, - function: String = #function, - line: Int = #line) { - _parraLog( - message: .string(message), - extra: extra, - level: .trace, - fileID: fileID, - function: function, - line: line - ) -} - -public func parraLogDebug(_ message: @autoclosure @escaping () -> String, - _ extra: [String: Any] = [:], - fileID: String = #fileID, - function: String = #function, - line: Int = #line) { - _parraLog( - message: .string(message), - extra: extra, - level: .debug, - fileID: fileID, - function: function, - line: line - ) -} - -public func parraLogInfo(_ message: @autoclosure @escaping () -> String, - _ extra: [String: Any] = [:], - fileID: String = #fileID, - function: String = #function, - line: Int = #line) { - _parraLog( - message: .string(message), - extra: extra, - level: .info, - fileID: fileID, - function: function, - line: line - ) -} - -public func parraLogWarn(_ message: @autoclosure @escaping () -> String, - _ extra: [String: Any] = [:], - fileID: String = #fileID, - function: String = #function, - line: Int = #line) { - _parraLog( - message: .string(message), - extra: extra, - level: .warn, - fileID: fileID, - function: function, - line: line - ) -} - -public func parraLogError(_ message: @autoclosure @escaping () -> String, - _ extra: [String: Any] = [:], - fileID: String = #fileID, - function: String = #function, - line: Int = #line) { - _parraLog( - message: .string(message), - extra: extra, - level: .error, - fileID: fileID, - function: function, - line: line - ) -} - -public func parraLogError(_ message: @autoclosure @escaping () -> ParraError, - fileID: String = #fileID, - function: String = #function, - line: Int = #line) { - _parraLog( - message: .error(message), - extra: [:], - level: .error, - fileID: fileID, - function: function, - line: line - ) -} - -public func parraLogError(_ message: @autoclosure @escaping () -> Error, - _ extra: [String: Any] = [:], - fileID: String = #fileID, - function: String = #function, - line: Int = #line) { - _parraLog( - message: .error(message), - extra: extra, - level: .error, - fileID: fileID, - function: function, - line: line - ) -} - -public func parraLogError(_ message: @autoclosure @escaping () -> String, - _ error: Error, - _ extra: [String: Any] = [:], - fileID: String = #fileID, - function: String = #function, - line: Int = #line) { - _parraLog( - message: .string(message), - extraError: error, - extra: extra, - level: .error, - fileID: fileID, - function: function, - line: line - ) -} - -public func parraLogFatal(_ message: @autoclosure @escaping () -> String, - _ extra: [String: Any] = [:], - fileID: String = #fileID, - function: String = #function, - line: Int = #line) { - _parraLog( - message: .string(message), - extra: extra, - level: .fatal, - fileID: fileID, - function: function, - line: line - ) -} - -public func parraLogFatal(_ message: @autoclosure @escaping () -> Error, - _ extra: [String: Any] = [:], - fileID: String = #fileID, - function: String = #function, - line: Int = #line) { - _parraLog( - message: .error(message), - extra: extra, - level: .fatal, - fileID: fileID, - function: function, - line: line - ) -} - -public func parraLogFatal(_ message: @autoclosure @escaping () -> String, - _ error: Error, - _ extra: [String: Any] = [:], - fileID: String = #fileID, - function: String = #function, - line: Int = #line) { - _parraLog( - message: .string(message), - extraError: error, - extra: extra, - level: .fatal, - fileID: fileID, - function: function, - line: line - ) -} diff --git a/Parra/Logger/ParraLoggerConfig.swift b/Parra/Logger/ParraLoggerConfig.swift deleted file mode 100644 index de8e0b41e..000000000 --- a/Parra/Logger/ParraLoggerConfig.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// ParraLoggerConfig.swift -// Parra -// -// Created by Mick MacCallum on 2/17/23. -// Copyright © 2023 Parra, Inc. All rights reserved. -// - -import Foundation - -public struct ParraLoggerConfig { - public var printTimestamps: Bool - public var printVerbosity: Bool - public var printVerbositySymbol: Bool - public var printThread: Bool - public var printCallsite: Bool - public var printModuleName: Bool - public var printFileName: Bool - - public var minimumAllowedLogLevel: ParraLogLevel = .default { - didSet { - ParraLogLevel.setMinAllowedLogLevel(minimumAllowedLogLevel) - } - } - - static let `default` = ParraLoggerConfig() - - init( - printTimestamps: Bool = true, - printVerbosity: Bool = true, - printVerbositySymbol: Bool = true, - printThread: Bool = false, - printCallsite: Bool = false, - printModuleName: Bool = true, - printFileName: Bool = true - ) { - self.printTimestamps = printTimestamps - self.printVerbosity = printVerbosity - self.printVerbositySymbol = printVerbositySymbol - self.printThread = printThread - self.printCallsite = printCallsite - self.printModuleName = printModuleName - self.printFileName = printFileName - - ParraLogLevel.setMinAllowedLogLevel(minimumAllowedLogLevel) - } - - enum Constant { - static let logEventPrefix = "parra:logger:" - static let logEventMessageKey = "\(logEventPrefix)message" - static let logEventLevelKey = "\(logEventPrefix)level" - static let logEventTimestampKey = "\(logEventPrefix)timestamp" - static let logEventFileKey = "\(logEventPrefix)file" - static let logEventModuleKey = "\(logEventPrefix)module" - static let logEventThreadKey = "\(logEventPrefix)thread" - static let logEventThreadIdKey = "\(logEventPrefix)threadId" - static let logEventExtraKey = "\(logEventPrefix)extra" - static let logEventCallStackKey = "\(logEventPrefix)stack" - } - - enum Environment { - static let allowedLogLevelOverrideKey = "PARRA_LOG_LEVEL" - } -} diff --git a/Parra/Logger/ParraWrappedLogMessage.swift b/Parra/Logger/ParraWrappedLogMessage.swift deleted file mode 100644 index 205839ed9..000000000 --- a/Parra/Logger/ParraWrappedLogMessage.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// ParraWrappedLogMessage.swift -// Parra -// -// Created by Mick MacCallum on 6/24/23. -// Copyright © 2023 Parra, Inc. All rights reserved. -// - -import Foundation - -public enum ParraWrappedLogMessage { - case string(() -> String) - case error(() -> Error) -} diff --git a/Parra/Managers/Data/ParraDataManager+Keys.swift b/Parra/Managers/Data/ParraDataManager+Keys.swift new file mode 100644 index 000000000..7d65e738f --- /dev/null +++ b/Parra/Managers/Data/ParraDataManager+Keys.swift @@ -0,0 +1,48 @@ +// +// ParraDataManager+Keys.swift +// Parra +// +// Created by Mick MacCallum on 3/13/22. +// + +import Foundation + +// !!! Think really before changing anything here! +internal extension ParraDataManager { + enum Base { + internal static let applicationSupportDirectory = Parra.fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + + internal static let documentDirectory = Parra.fileManager.urls( + for: .documentDirectory, + in: .userDomainMask + ).first! + + internal static let cachesURL = Parra.fileManager.urls( + for: .cachesDirectory, + in: .userDomainMask + ).first! + + internal static let homeUrl = URL( + fileURLWithPath: NSHomeDirectory(), + isDirectory: true + ) + } + + enum Directory { + static let storageDirectoryName = "storage" + } + + enum Path { + internal static let networkCachesDirectory = Base.cachesURL.appendDirectory("parra-network-cache") + + internal static let parraDirectory = Base.applicationSupportDirectory.appendDirectory("parra") + } + + enum Key { + internal static let userCredentialsKey = "com.parra.usercredential" + internal static let userSessionsKey = "com.parra.usersession" + } +} diff --git a/Parra/Managers/Data/ParraDataManager.swift b/Parra/Managers/Data/ParraDataManager.swift new file mode 100644 index 000000000..6b329a371 --- /dev/null +++ b/Parra/Managers/Data/ParraDataManager.swift @@ -0,0 +1,32 @@ +// +// ParraDataManager.swift +// Parra +// +// Created by Mick MacCallum on 3/13/22. +// + +import Foundation + +internal class ParraDataManager { + internal let baseDirectory: URL + internal let credentialStorage: CredentialStorage + internal let sessionStorage: SessionStorage + + internal init( + baseDirectory: URL, + credentialStorage: CredentialStorage, + sessionStorage: SessionStorage + ) { + self.baseDirectory = baseDirectory + self.credentialStorage = credentialStorage + self.sessionStorage = sessionStorage + } + + internal func getCurrentCredential() async -> ParraCredential? { + return await credentialStorage.currentCredential() + } + + internal func updateCredential(credential: ParraCredential?) async { + await credentialStorage.updateCredential(credential: credential) + } +} diff --git a/Parra/Managers/Network/ParraNetworkManager+Endpoints.swift b/Parra/Managers/Network/ParraNetworkManager+Endpoints.swift new file mode 100644 index 000000000..16dac745c --- /dev/null +++ b/Parra/Managers/Network/ParraNetworkManager+Endpoints.swift @@ -0,0 +1,160 @@ +// +// ParraNetworkManager+Endpoints.swift +// Parra +// +// Created by Mick MacCallum on 3/12/22. +// + +import UIKit + +fileprivate let logger = Logger(category: "Endpoints") + +internal extension ParraNetworkManager { + // MARK: - Feedback + + func getCards(appArea: ParraQuestionAppArea) async throws -> [ParraCardItem] { + var queryItems: [String : String] = [:] + // It is important that an app area name is only provided if a specific one is meant to be returned. + // If the app area type is `all` then there should not be a `app_area` key present in the request. + if let appAreaName = appArea.parameterized { + queryItems["app_area_id"] = appAreaName + } + + let response: AuthenticatedRequestResult = await performAuthenticatedRequest( + endpoint: .getCards, + queryItems: queryItems, + cachePolicy: .reloadIgnoringLocalAndRemoteCacheData + ) + + switch response.result { + case .success(let cardsResponse): + return cardsResponse.items + case .failure(let error): + logger.error("Error fetching cards from Parra", error) + + throw error + } + } + + /// Submits the provided list of CompleteCards as answers to the cards linked to them. + func bulkAnswerQuestions(cards: [CompletedCard]) async throws { + if cards.isEmpty { + return + } + + let response: AuthenticatedRequestResult = await performAuthenticatedRequest( + endpoint: .postBulkAnswerQuestions, + cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, + body: cards.map { CompletedCardUpload(completedCard: $0) } + ) + + switch response.result { + case .success: + return + case .failure(let error): + logger.error("Error submitting card responses to Parra", error) + + throw error + } + } + + /// Fetches the feedback form with the provided id from the Parra API. + func getFeedbackForm(with formId: String) async throws -> ParraFeedbackFormResponse { + guard let escapedFormId = formId.addingPercentEncoding( + withAllowedCharacters: .urlPathAllowed + ) else { + throw ParraError.message("Provided form id: \(formId) contains invalid characters. Must be URL path encodable.") + } + + let response: AuthenticatedRequestResult = await performAuthenticatedRequest( + endpoint: .getFeedbackForm(formId: escapedFormId) + ) + + switch response.result { + case .success(let formResponse): + return formResponse + case .failure(let error): + logger.error("Error fetching feedback form from Parra", error) + + throw error + } + } + + /// Submits the provided data as answers for the form with the provided id. + func submitFeedbackForm( + with formId: String, + data: [FeedbackFormField: String] + ) async throws { + // Map of FeedbackFormField.name -> a String (or value if applicable) + let body = data.reduce([String : String]()) { partialResult, entry in + var next = partialResult + next[entry.key.name] = entry.value + return next + } + + guard let escapedFormId = formId.addingPercentEncoding( + withAllowedCharacters: .urlPathAllowed + ) else { + throw ParraError.message("Provided form id: \(formId) contains invalid characters. Must be URL path encodable.") + } + + let response: AuthenticatedRequestResult = await performAuthenticatedRequest( + endpoint: .postSubmitFeedbackForm(formId: escapedFormId), + body: body + ) + + switch response.result { + case .success: + return + case .failure(let error): + logger.error("Error submitting form to Parra", error) + + throw error + } + } + + // MARK: - Sessions + + func submitSession( + _ sessionUpload: ParraSessionUpload + ) async throws -> AuthenticatedRequestResult { + guard let tenantId = await configState.getCurrentState().tenantId else { + throw ParraError.notInitialized + } + + let response: AuthenticatedRequestResult = await performAuthenticatedRequest( + endpoint: .postBulkSubmitSessions(tenantId: tenantId), + config: .defaultWithRetries, + body: sessionUpload + ) + + return response + } + + // MARK: - Push + + func uploadPushToken(token: String) async throws { + guard let tenantId = await configState.getCurrentState().tenantId else { + throw ParraError.notInitialized + } + + let body: [String : String] = [ + "token": token, + "type": "apns" + ] + + let response: AuthenticatedRequestResult = await performAuthenticatedRequest( + endpoint: .postPushTokens(tenantId: tenantId), + body: body + ) + + switch response.result { + case .success: + return + case .failure(let error): + logger.error("Error uploading device push token to Parra", error) + + throw error + } + } +} diff --git a/Parra/Managers/Network/ParraNetworkManager+ParraUrlSessionDelegate.swift b/Parra/Managers/Network/ParraNetworkManager+ParraUrlSessionDelegate.swift new file mode 100644 index 000000000..50d9618a8 --- /dev/null +++ b/Parra/Managers/Network/ParraNetworkManager+ParraUrlSessionDelegate.swift @@ -0,0 +1,62 @@ +// +// ParraNetworkManager+ParraUrlSessionDelegate.swift +// Parra +// +// Created by Mick MacCallum on 7/4/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +extension ParraNetworkManager: ParraUrlSessionDelegate { + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didFinishCollecting metrics: URLSessionTaskMetrics + ) async { + Logger.debug( + "Finished collecting metrics for task: \(task.taskIdentifier)", + [ + "redirectCount": metrics.redirectCount, + "taskInterval": [ + "duration": String( + format: "%0.3f", metrics.taskInterval.duration + ), + "unit": "seconds" + ], + "transactionMetrics": metrics.transactionMetrics.map { tx in + var metrics: [String : Any] = [ + "isCellular" : tx.isCellular, + "isExpensive" : tx.isExpensive, + "isConstrained" : tx.isConstrained, + "isProxyConnection" : tx.isProxyConnection, + "isReusedConnection" : tx.isReusedConnection, + "isMultipath" : tx.isMultipath + ] + + if let fetchStartDate = tx.fetchStartDate { + metrics["fetchStartDate"] = fetchStartDate.timeIntervalSince1970 + } + + if let responseEndDate = tx.responseEndDate { + metrics["responseEndDate"] = responseEndDate.timeIntervalSince1970 + } + + if let networkProtocolName = tx.networkProtocolName { + metrics["networkProtocolName"] = networkProtocolName + } + + if let remotePort = tx.remotePort { + metrics["remotePort"] = remotePort + } + + if let remoteAddress = tx.remoteAddress { + metrics["remoteAddress"] = remoteAddress + } + + return metrics + } + ] + ) + } +} diff --git a/Parra/Managers/ParraNetworkManager.swift b/Parra/Managers/Network/ParraNetworkManager.swift similarity index 63% rename from Parra/Managers/ParraNetworkManager.swift rename to Parra/Managers/Network/ParraNetworkManager.swift index 620db35c6..a84e29208 100644 --- a/Parra/Managers/ParraNetworkManager.swift +++ b/Parra/Managers/Network/ParraNetworkManager.swift @@ -8,14 +8,13 @@ import Foundation import UIKit -public typealias NetworkCompletionHandler = (Result) -> Void +fileprivate let logger = Logger(category: "Network Manager") -internal let EmptyJsonObjectData = "{}".data(using: .utf8)! +internal typealias NetworkCompletionHandler = (Result) -> Void -internal struct EmptyRequestObject: Codable {} -internal struct EmptyResponseObject: Codable {} - -internal actor ParraNetworkManager: NetworkManagerType { +internal actor ParraNetworkManager: NetworkManagerType, ParraModuleStateAccessor { + internal let state: ParraState + internal let configState: ParraConfigState private let dataManager: ParraDataManager private var authenticationProvider: ParraAuthenticationProviderFunction? @@ -23,13 +22,23 @@ internal actor ParraNetworkManager: NetworkManagerType { private let urlSession: URLSessionType private let jsonEncoder: JSONEncoder private let jsonDecoder: JSONDecoder + + private lazy var urlSessionDelegateProxy: ParraNetworkManagerUrlSessionDelegateProxy = { + return ParraNetworkManagerUrlSessionDelegateProxy( + delegate: self + ) + }() internal init( + state: ParraState, + configState: ParraConfigState, dataManager: ParraDataManager, urlSession: URLSessionType, - jsonEncoder: JSONEncoder = JSONEncoder.parraEncoder, - jsonDecoder: JSONDecoder = JSONDecoder.parraDecoder + jsonEncoder: JSONEncoder, + jsonDecoder: JSONDecoder ) { + self.state = state + self.configState = configState self.dataManager = dataManager self.urlSession = urlSession self.jsonEncoder = jsonEncoder @@ -45,35 +54,37 @@ internal actor ParraNetworkManager: NetworkManagerType { } internal func refreshAuthentication() async throws -> ParraCredential { - parraLogDebug("Performing reauthentication for Parra") + return try await logger.withScope { logger in + logger.debug("Performing reauthentication for Parra") - guard let authenticationProvider = authenticationProvider else { - throw ParraError.missingAuthentication - } - - do { - parraLogInfo("Invoking Parra authentication provider") + guard let authenticationProvider = authenticationProvider else { + throw ParraError.missingAuthentication + } - let token = try await authenticationProvider() - let credential = ParraCredential(token: token) - - await dataManager.updateCredential( - credential: credential - ) + do { + logger.info("Invoking Parra authentication provider") - parraLogInfo("Reauthentication with Parra succeeded") + let token = try await authenticationProvider() + let credential = ParraCredential(token: token) - return credential - } catch let error { - let framing = "╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍" - parraLogError("\n\(framing)\n\nReauthentication with Parra failed.\nInvoking the authProvider passed to Parra.initialize failed with error: \(error.localizedDescription)\n\n\(framing)") - throw ParraError.authenticationFailed(error.localizedDescription) + await dataManager.updateCredential( + credential: credential + ) + + logger.info("Reauthentication with Parra succeeded") + + return credential + } catch let error { + let framing = "╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍" + logger.error("\n\(framing)\n\nReauthentication with Parra failed.\nInvoking the authProvider passed to Parra.initialize failed with error: \(error.localizedDescription)\n\n\(framing)") + throw ParraError.authenticationFailed(error.localizedDescription) + } } } internal func performAuthenticatedRequest( endpoint: ParraEndpoint, - queryItems: [String: String] = [:], + queryItems: [String : String] = [:], config: RequestConfig = .default, cachePolicy: URLRequest.CachePolicy? = nil ) async -> AuthenticatedRequestResult { @@ -88,7 +99,7 @@ internal actor ParraNetworkManager: NetworkManagerType { internal func performAuthenticatedRequest( endpoint: ParraEndpoint, - queryItems: [String: String] = [:], + queryItems: [String : String] = [:], config: RequestConfig = .default, cachePolicy: URLRequest.CachePolicy? = nil, body: U @@ -98,16 +109,16 @@ internal actor ParraNetworkManager: NetworkManagerType { var responseAttributes: AuthenticatedRequestAttributeOptions = [] - parraLogTrace("Performing authenticated request to route: \(method.rawValue) \(route)") + logger.trace("Performing authenticated request to route: \(method.rawValue) \(route)") do { - guard let applicationId = await ParraConfigState.shared.getCurrentState().applicationId else { + guard let applicationId = await configState.getCurrentState().applicationId else { throw ParraError.notInitialized } let url = Parra.InternalConstants.parraApiRoot.appendingPathComponent(route) guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - throw ParraError.custom("Failed to create components for url: \(url)", nil) + throw ParraError.generic("Failed to create components for url: \(url)", nil) } urlComponents.setQueryItems(with: queryItems) @@ -141,7 +152,7 @@ internal actor ParraNetworkManager: NetworkManagerType { switch result { case .success(let data): - parraLogTrace("Parra client received success response") + logger.trace("Parra client received success response") let response = try jsonDecoder.decode(T.self, from: data) @@ -167,20 +178,20 @@ internal actor ParraNetworkManager: NetworkManagerType { ) async -> (Result, AuthenticatedRequestAttributeOptions) { do { var request = request - request.setValue("Bearer \(credential.token)", forHTTPHeaderField: .authorization) + request.setValue(for: .authorization(.bearer(credential.token))) let (data, response) = try await performAsyncDataDask(request: request) - parraLogTrace("Parra client received response. Status: \(response.statusCode)") + logger.trace("Parra client received response. Status: \(response.statusCode)") switch (response.statusCode, config.shouldReauthenticate) { case (204, _): return (.success(EmptyJsonObjectData), config.attributes) case (401, true): - parraLogTrace("Request required reauthentication") + logger.trace("Request required reauthentication") let newCredential = try await refreshAuthentication() - request.setValue("Bearer \(newCredential.token)", forHTTPHeaderField: .authorization) + request.setValue(for: .authorization(.bearer(newCredential.token))) return await performRequest( request: request, @@ -190,52 +201,23 @@ internal actor ParraNetworkManager: NetworkManagerType { .withAttribute(.requiredReauthentication) ) case (400...499, _): -#if DEBUG - if let dataString = try? jsonDecoder.decode(AnyCodable.self, from: data), - let prettyData = try? jsonEncoder.encode(dataString), - let prettyString = String(data: prettyData, encoding: .utf8) { - - parraLogTrace("Received 400...499 status in response") - if data != EmptyJsonObjectData { - parraLogTrace(prettyString) - } - - if let body = request.httpBody, let bodyString = String(data: body, encoding: .utf8) { - parraLogTrace("Request data was:") - parraLogTrace(bodyString) - } - } -#endif - return ( .failure(ParraError.networkError( - status: response.statusCode, - message: response.debugDescription, - request: request + request: request, + response: response, + responseData: data )), config.attributes ) case (500...599, _): -#if DEBUG - if let dataString = try? jsonDecoder.decode(AnyCodable.self, from: data), - let prettyData = try? jsonEncoder.encode(dataString), - let prettyString = String(data: prettyData, encoding: .utf8) { - - parraLogTrace("Received 500...599 status in response") - if data != EmptyJsonObjectData { - parraLogTrace(prettyString) - } - } -#endif - if config.shouldRetry { - parraLogTrace("Retrying previous request") + logger.trace("Retrying previous request") let nextConfig = config .afterRetrying() .withAttribute(.requiredRetry) - try await Task.sleep(nanoseconds: nextConfig.retryDelayNs) + try await Task.sleep(for: nextConfig.retryDelay) return await performRequest( request: request, @@ -251,9 +233,9 @@ internal actor ParraNetworkManager: NetworkManagerType { return ( .failure(ParraError.networkError( - status: response.statusCode, - message: response.debugDescription, - request: request + request: request, + response: response, + responseData: data )), attributes ) @@ -279,12 +261,12 @@ internal actor ParraNetworkManager: NetworkManagerType { request.httpBody = try jsonEncoder.encode(["user_id": userId]) request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData - guard let authData = ("api_key:" + apiKeyId).data(using: .utf8)?.base64EncodedString() else { - throw ParraError.custom("Unable to encode API key as NSData", nil) + guard let authToken = ("api_key:" + apiKeyId).data(using: .utf8)?.base64EncodedString() else { + throw ParraError.generic("Unable to encode API key as NSData", nil) } addHeaders(to: &request, endpoint: endpoint, for: applicationId) - request.setValue("Basic \(authData)", forHTTPHeaderField: .authorization) + request.setValue(for: .authorization(.basic(authToken))) let (data, response) = try await performAsyncDataDask(request: request) @@ -295,22 +277,22 @@ internal actor ParraNetworkManager: NetworkManagerType { return credential.token default: throw ParraError.networkError( - status: response.statusCode, - message: response.debugDescription, - request: request + request: request, + response: response, + responseData: data ) } } internal func performBulkAssetCachingRequest(assets: [Asset]) async { - parraLogTrace("Performing bulk asset caching request for \(assets.count) asset(s)") + logger.trace("Performing bulk asset caching request for \(assets.count) asset(s)") let _ = await assets.asyncMap { asset in return try? await fetchAsset(asset: asset) } } internal func fetchAsset(asset: Asset) async throws -> UIImage? { - parraLogTrace("Fetching asset: \(asset.id)", [ + logger.trace("Fetching asset: \(asset.id)", [ "url": asset.url ]) @@ -320,7 +302,7 @@ internal actor ParraNetworkManager: NetworkManagerType { let httpResponse = response as! HTTPURLResponse defer { - parraLogTrace("Caching asset: \(asset.id)") + logger.trace("Caching asset: \(asset.id)") let cacheResponse = CachedURLResponse( response: response, @@ -332,37 +314,37 @@ internal actor ParraNetworkManager: NetworkManagerType { } if httpResponse.statusCode < 300 { - parraLogTrace("Successfully retreived image for asset: \(asset.id)") + logger.trace("Successfully retreived image for asset: \(asset.id)") return UIImage(data: data) } - parraLogTrace("Failed to download image for asset: \(asset.id)") + logger.warn("Failed to download image for asset: \(asset.id)") return nil } internal func isAssetCached(asset: Asset) -> Bool { - parraLogTrace("Checking if asset is cached: \(asset.id)") + logger.trace("Checking if asset is cached: \(asset.id)") guard let cache = urlSession.configuration.urlCache else { - parraLogTrace("Cache is missing") + logger.trace("Cache is missing") return false } guard let cachedResponse = cache.cachedResponse(for: request(for: asset)) else { - parraLogTrace("Cache miss for asset: \(asset.id)") + logger.trace("Cache miss for asset: \(asset.id)") return false } guard cachedResponse.storagePolicy != .notAllowed else { - parraLogTrace("Storage policy disallowed for asset: \(asset.id)") + logger.trace("Storage policy disallowed for asset: \(asset.id)") return false } - parraLogTrace("Cache hit for asset: \(asset.id)") + logger.trace("Cache hit for asset: \(asset.id)") return true } @@ -379,16 +361,35 @@ internal actor ParraNetworkManager: NetworkManagerType { } private func performAsyncDataDask(request: URLRequest) async throws -> (Data, HTTPURLResponse) { + #if DEBUG - if NSClassFromString("XCTestCase") != nil { - try await Task.sleep(nanoseconds: 1_000_000_000) + // There is a different delay added in the UrlSession mocks that + // slows down tests. This delay is specific to helping prevent us + // from introducing UI without proper loading states. + if NSClassFromString("XCTestCase") == nil { + try await Task.sleep(for: 1.0) } #endif - let (data, response) = try await urlSession.dataForRequest(for: request, delegate: nil) + let (data, response) = try await urlSession.dataForRequest( + for: request, + delegate: urlSessionDelegateProxy + ) + + guard let httpResponse = response as? HTTPURLResponse else { + // It is documented that for data tasks, response is always actually + // HTTPURLResponse, so this should never happen. + throw ParraError.message("Unexpected networking error. HTTP response was unexpected class") + } + + Parra.logEvent( + .httpRequest( + request: request, + response: httpResponse + ) + ) - // It is documented that for data tasks, response is always actually HTTPURLResponse - return (data, response as! HTTPURLResponse) + return (data, httpResponse) } private func addHeaders( @@ -396,16 +397,18 @@ internal actor ParraNetworkManager: NetworkManagerType { endpoint: ParraEndpoint, for applicationId: String ) { - request.setValue("application/json", forHTTPHeaderField: .accept) + request.setValue(for: .accept(.applicationJson)) request.setValue(for: .applicationId(applicationId)) if endpoint.method.allowsBody { - request.setValue("application/json", forHTTPHeaderField: .contentType) + request.setValue(for: .contentType(.applicationJson)) } addTrackingHeaders(toRequest: &request, for: endpoint) - parraLogTrace("Finished attaching request headers for endpoint: \(endpoint.displayName)", request.allHTTPHeaderFields ?? [:]) + let headers = request.allHTTPHeaderFields ?? [:] + + logger.trace("Finished attaching request headers for endpoint: \(endpoint.displayName)", headers) } private func addTrackingHeaders( @@ -418,7 +421,7 @@ internal actor ParraNetworkManager: NetworkManagerType { let headers = ParraHeader.trackingHeaderDictionary - parraLogTrace("Adding extra tracking headers to tracking enabled endpoint: \(endpoint.displayName)") + logger.trace("Adding extra tracking headers to tracking enabled endpoint: \(endpoint.displayName)") for (header, value) in headers { request.setValue(value, forHTTPHeaderField: header) diff --git a/Parra/Managers/ParraDataManager+Keys.swift b/Parra/Managers/ParraDataManager+Keys.swift deleted file mode 100644 index 0fca36a19..000000000 --- a/Parra/Managers/ParraDataManager+Keys.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// ParraDataManager+Keys.swift -// Parra -// -// Created by Mick MacCallum on 3/13/22. -// - -import Foundation - -// !!! Think really before changing anything here! -public extension ParraDataManager { - enum Base { - public static let applicationSupportDirectory = FileManager.default.urls( - for: .applicationSupportDirectory, - in: .userDomainMask - ).first! - - public static let cachesURL = FileManager.default.urls( - for: .cachesDirectory, - in: .userDomainMask - ).first! - } - - enum Path { - internal static let networkCachesDirectory = Base.cachesURL.safeAppendDirectory("ParraNetworkCache") - public static let parraDirectory = Base.applicationSupportDirectory.safeAppendDirectory("parra") - } - - enum Key { - internal static let userCredentialsKey = "com.parra.usercredential" - internal static let userSessionsKey = "com.parra.usersession" - } -} diff --git a/Parra/Managers/ParraDataManager.swift b/Parra/Managers/ParraDataManager.swift deleted file mode 100644 index 970b94bdd..000000000 --- a/Parra/Managers/ParraDataManager.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// ParraDataManager.swift -// Parra -// -// Created by Mick MacCallum on 3/13/22. -// - -import Foundation - -public class ParraDataManager { - internal fileprivate(set) var credentialStorage: CredentialStorage - internal fileprivate(set) var sessionStorage: SessionStorage - - internal init() { - let folder = Parra.persistentStorageFolder() - - let credentialStorageModule = ParraStorageModule( - dataStorageMedium: .fileSystem( - folder: folder, - fileName: ParraDataManager.Key.userCredentialsKey, - storeItemsSeparately: false - ) - ) - - let sessionStorageModule = ParraStorageModule( - dataStorageMedium: .fileSystem( - folder: folder.appending("/sessions"), - fileName: ParraDataManager.Key.userSessionsKey, - storeItemsSeparately: true - ) - ) - - self.credentialStorage = CredentialStorage(storageModule: credentialStorageModule) - self.sessionStorage = SessionStorage(storageModule: sessionStorageModule) - } - - internal func getCurrentCredential() async -> ParraCredential? { - return await credentialStorage.currentCredential() - } - - internal func updateCredential(credential: ParraCredential?) async { - await credentialStorage.updateCredential(credential: credential) - } -} - -internal class MockDataManager: ParraDataManager { - override init() { - super.init() - - let credentialStorageModule = ParraStorageModule( - dataStorageMedium: .memory - ) - - self.credentialStorage = CredentialStorage(storageModule: credentialStorageModule) - } -} diff --git a/Parra/Managers/Sessions/ParraSessionManager.swift b/Parra/Managers/Sessions/ParraSessionManager.swift deleted file mode 100644 index 5c285803b..000000000 --- a/Parra/Managers/Sessions/ParraSessionManager.swift +++ /dev/null @@ -1,193 +0,0 @@ -// -// ParraSessionManager.swift -// Parra -// -// Created by Mick MacCallum on 11/19/22. -// Copyright © 2022 Parra, Inc. All rights reserved. -// - -import Foundation -import UIKit - -/// ParraSessionManager -/// Session Lifecycle -/// -internal actor ParraSessionManager { - private let dataManager: ParraDataManager - private let networkManager: ParraNetworkManager - private var currentSession: ParraSession? - private var userProperties: [String: AnyCodable] = [:] - - internal init(dataManager: ParraDataManager, - networkManager: ParraNetworkManager) { - - self.dataManager = dataManager - self.networkManager = networkManager - self.currentSession = ParraSession() - } - - internal func hasDataToSync() async -> Bool { - let persistedSessionCount = await dataManager.sessionStorage.numberOfTrackedSessions() - // Sync if there are any sessions that aren't still in progress, or if the session - // in progress has new events to report. - - if persistedSessionCount > 1 { - return true - } - - return currentSession?.hasNewData ?? false - } - - func clearSessionHistory() async { - parraLogDebug("Clearing previous session history") - - let allSessions = await dataManager.sessionStorage.allTrackedSessions() - let sessionIds = allSessions.reduce(Set()) { partialResult, session in - var next = partialResult - next.insert(session.sessionId) - return next - } - - await dataManager.sessionStorage.deleteSessions(with: sessionIds) - } - - func synchronizeData() async -> ParraSessionsResponse? { - guard var currentSession else { - return nil - } - - // Reset and update the cache for the current session so the most up to take data is uploaded. - currentSession.resetSentData() - await dataManager.sessionStorage.update(session: currentSession) - - let sessions = await dataManager.sessionStorage.allTrackedSessions() - - var sessionResponse: ParraSessionsResponse? - do { - let (completedSessionIds, nextSessionResponse) = try await Parra.API.Sessions.bulkSubmitSessions( - sessions: sessions - ) - - sessionResponse = nextSessionResponse - - // Remove all of the successfully uploaded sessions except for the - // session that is in progress. - await dataManager.sessionStorage.deleteSessions( - with: completedSessionIds.subtracting([currentSession.sessionId]) - ) - } catch let error { - parraLogError("Syncing sessions failed", error) - } - - self.currentSession = currentSession - - return sessionResponse - } - - internal func logEvent(_ name: String, - params: [Key: Any]) async where Key: CustomStringConvertible { - - await createSessionIfNotExists() - - guard var currentSession else { - return - } - - parraLogDebug("Logging event", [ - "name": String(describing: name), - "params": String(describing: params), - ]) - - let metadata: [String: AnyCodable] = Dictionary( - uniqueKeysWithValues: params.map { key, value in - (key.description, .init(value)) - }) - - let newEvent = ParraSessionEvent( - name: name, - createdAt: Date(), - metadata: metadata - ) - - currentSession.updateUserProperties(userProperties) - currentSession.addEvent(newEvent) - - await dataManager.sessionStorage.update(session: currentSession) - - self.currentSession = currentSession - } - - internal func setUserProperty(_ value: Any?, - forKey key: Key) async where Key: CustomStringConvertible { - - await createSessionIfNotExists() - - guard var currentSession else { - return - } - - parraLogDebug("Updating user property", [ - "key": key.description, - "new value": String(describing: value), - "old value": String(describing: userProperties[key.description]) - ]) - - if let value { - userProperties[key.description] = .init(value) - } else { - userProperties.removeValue(forKey: key.description) - } - - currentSession.updateUserProperties(userProperties) - - await dataManager.sessionStorage.update(session: currentSession) - - self.currentSession = currentSession - } - - internal func resetSession() async { - guard var currentSession else { - return - } - - currentSession.end() - currentSession.resetSentData() - - await dataManager.sessionStorage.update(session: currentSession) - - self.currentSession = nil - } - - internal func endSession() async { - guard var currentSession else { - return - } - - currentSession.end() - - await dataManager.sessionStorage.update(session: currentSession) - - self.currentSession = nil - } - - internal func createSessionIfNotExists() async { - guard currentSession == nil else { - parraLogTrace("Session is already in progress. Skipping creating new one.") - - return - } - - var currentSession = ParraSession() - - currentSession.updateUserProperties(userProperties) - - // TODO: Instead of logging events for things like app state when session is first created - // TODO: Add a new session property deviceProperties, which can be updated whenever a event is logged. - // TODO: Best way to do this is probably to make an event to log called devicePropertiesChanged that - // TODO: contains all of the changed properties. - - await dataManager.sessionStorage.update(session: currentSession) - - self.currentSession = currentSession - } -} diff --git a/Parra/Managers/Sync/ParraSyncManager.swift b/Parra/Managers/Sync/ParraSyncManager.swift index 90ca8227a..39c0d49a3 100644 --- a/Parra/Managers/Sync/ParraSyncManager.swift +++ b/Parra/Managers/Sync/ParraSyncManager.swift @@ -8,6 +8,11 @@ import Foundation import UIKit +// TODO: What happens to the timer when the app goes to the background? +// TODO: Timer should have exponential backoff. New eventual sync enqueued should reset this. + +fileprivate let logger = Logger(bypassEventCreation: true, category: "Sync Manager") + internal enum ParraSyncMode: String { case immediate case eventual @@ -15,75 +20,111 @@ internal enum ParraSyncMode: String { /// Manager used to facilitate the synchronization of Parra data stored locally with the Parra API. internal actor ParraSyncManager { - internal enum Constant { - static let eventualSyncDelay: TimeInterval = 30.0 - } - /// Whether or not new attempts to sync occured while a sync was in progress. Many sync events could be received while a sync /// is in progress so we just track whether any happened. If any happen then we will perform a subsequent sync when the original /// is completed. internal private(set) var enqueuedSyncMode: ParraSyncMode? = nil - + + internal let state: ParraState + internal let syncState: ParraSyncState + nonisolated internal let syncDelay: TimeInterval + private let networkManager: ParraNetworkManager private let sessionManager: ParraSessionManager private let notificationCenter: NotificationCenterType @MainActor private var syncTimer: Timer? - + + private var lastSyncCompleted: Date? + internal init( + state: ParraState, + syncState: ParraSyncState, networkManager: ParraNetworkManager, sessionManager: ParraSessionManager, - notificationCenter: NotificationCenterType + notificationCenter: NotificationCenterType, + syncDelay: TimeInterval = 30.0 ) { + self.state = state + self.syncState = syncState self.networkManager = networkManager self.sessionManager = sessionManager self.notificationCenter = notificationCenter + self.syncDelay = syncDelay } /// Used to send collected data to the Parra API. Invoked automatically internally, but can be invoked externally as necessary. internal func enqueueSync(with mode: ParraSyncMode) async { guard await networkManager.getAuthenticationProvider() != nil else { - parraLogTrace("Skipping \(mode) sync. Authentication provider is unset.") + await stopSyncTimer() + logger.trace("Skipping \(mode) sync. Authentication provider is unset.") return } - guard await hasDataToSync() else { - parraLogDebug("Skipping \(mode) sync. No sync necessary.") + guard await hasDataToSync(since: lastSyncCompleted) else { + logger.debug("Skipping \(mode) sync. No sync necessary.") return } - parraLogDebug("Enqueuing sync: \(mode)") + logger.debug("Preparing to enqueue sync: \(mode)") + + if await syncState.isSyncing() { + let logPrefix = "Sync already in progress." + switch mode { + case .immediate: + logger.debug("\(logPrefix) Sync was requested immediately. Will sync again upon current sync completion.") - if await SyncState.shared.isSyncing() { - if mode == .immediate { - parraLogDebug("Sync already in progress. Sync was requested immediately. Will sync again upon current sync completion.") - enqueuedSyncMode = .immediate - } else { - parraLogDebug("Sync already in progress. Marking enqued sync.") - + case .eventual: + logger.debug("\(logPrefix) Marking enqued sync.") + if enqueuedSyncMode != .immediate { // An enqueued eventual sync shouldn't override an enqueued immediate sync. enqueuedSyncMode = .eventual } } - + return } - Task { - await self.sync() + let logPrefix = "No sync in progress." + switch mode { + case .immediate: + logger.debug("\(logPrefix) Initiating immediate sync.") + enqueuedSyncMode = nil + // Should not be awaited. enqueueSync returns when the sync job is added + // to the queue. + Task { + await self.sync() + } + case .eventual: + enqueuedSyncMode = .eventual + logger.debug("\(logPrefix) Queuing eventual sync.") + + if !(await isSyncTimerActive()) { + await startSyncTimer() + } } - - parraLogDebug("Sync enqued") } - private func sync() async { - await SyncState.shared.beginSync() - + private func sync( + isRepeat: Bool = false + ) async { + if !isRepeat { + if await syncState.isSyncing() { + logger.warn("Attempted to start a sync while one is in progress.") + + return + } + + await syncState.beginSync() + } + let syncToken = UUID().uuidString + // This notification is deliberately kept before the check for if + // there is data to sync. await notificationCenter.postAsync( name: Parra.syncDidBeginNotification, object: self, @@ -92,8 +133,14 @@ internal actor ParraSyncManager { ] ) + var shouldRepeatSync = false + defer { + lastSyncCompleted = .now + Task { + // Ensure that the notification that the current sync has ended is sent + // before the next sync begins. await notificationCenter.postAsync( name: Parra.syncDidEndNotification, object: self, @@ -101,57 +148,71 @@ internal actor ParraSyncManager { Parra.Constants.syncTokenKey: syncToken ] ) + + if shouldRepeatSync { + // Must be kept inside a Task block to avoid the current sync's + // completion awaiting the next sync. + await sync(isRepeat: true) + } } } - guard await hasDataToSync() else { - parraLogDebug("No data available to sync") + guard await hasDataToSync(since: lastSyncCompleted) else { + logger.debug("No data available to sync") - await SyncState.shared.endSync() + await syncState.endSync() return } - - parraLogDebug("Starting sync") - - let start = CFAbsoluteTimeGetCurrent() - parraLogTrace("Sending sync data...") + let syncStartMarker = logger.debug("Starting sync") + do { - try await performSync() + try await performSync(with: syncToken) - let duration = CFAbsoluteTimeGetCurrent() - start - parraLogTrace("Sync data sent. Took \(duration)(s)") + logger.measureTime(since: syncStartMarker, message: "Sync complete") } catch let error { - parraLogError("Error performing sync", error) + logger.error("Error performing sync", error) + + if enqueuedSyncMode == .immediate { + // Try to avoid the case there there might be something wrong with the data + // being synced that could cause it to fail again if synced immediately. + // This will at least keep us in a failure loop at the duration of the sync + // timer. + logger.debug("Immediate sync was enqueued after error. Resetting to eventual.") + enqueuedSyncMode = .eventual + } + // TODO: Maybe cancel the sync timer, double the countdown then start a new one? } guard let enqueuedSyncMode else { - parraLogDebug("No more jobs found. Completing sync.") + logger.debug("No more jobs found. Completing sync.") - await SyncState.shared.endSync() + await syncState.endSync() return } - parraLogDebug("More sync jobs were enqueued. Repeating sync.") + logger.debug("More sync jobs were enqueued. Repeating sync.") self.enqueuedSyncMode = nil switch enqueuedSyncMode { case .immediate: - await sync() + shouldRepeatSync = true case .eventual: - await SyncState.shared.endSync() + await syncState.endSync() } } - private func performSync() async throws { + private func performSync( + with token: String + ) async throws { var syncError: Error? // Rethrow after receiving an error for throttling, but allow each module to attempt a sync once - for (_, module) in await ParraGlobalState.shared.getAllRegisteredModules() { + for module in await state.getAllRegisteredModules() { do { try await module.synchronizeData() } catch let error { @@ -164,10 +225,12 @@ internal actor ParraSyncManager { } } - private func hasDataToSync() async -> Bool { + private func hasDataToSync(since date: Date?) async -> Bool { + let allRegisteredModules = await state.getAllRegisteredModules() var shouldSync = false - for (_, module) in await ParraGlobalState.shared.getAllRegisteredModules() { - if await module.hasDataToSync() { + + for module in allRegisteredModules { + if await module.hasDataToSync(since: date) { shouldSync = true break } @@ -177,15 +240,23 @@ internal actor ParraSyncManager { } @MainActor - internal func startSyncTimer() { + internal func startSyncTimer( + willSyncHandler: (() -> Void)? = nil + ) { stopSyncTimer() - parraLogTrace("Starting sync timer") + logger.trace("Starting sync timer") syncTimer = Timer.scheduledTimer( - withTimeInterval: Constant.eventualSyncDelay, + withTimeInterval: syncDelay, repeats: true - ) { timer in - parraLogTrace("Sync timer fired") + ) { [weak self] timer in + guard let self else { + return + } + + logger.trace("Sync timer fired") + + willSyncHandler?() Task { await self.enqueueSync(with: .immediate) @@ -199,9 +270,22 @@ internal actor ParraSyncManager { return } - parraLogTrace("Stopping sync timer") + logger.trace("Stopping sync timer") syncTimer.invalidate() self.syncTimer = nil } + + @MainActor + internal func isSyncTimerActive() -> Bool { + guard let syncTimer else { + return false + } + + return syncTimer.isValid + } + + internal func cancelEnqueuedSyncs() { + enqueuedSyncMode = nil + } } diff --git a/Parra/Managers/Sync/Syncable.swift b/Parra/Managers/Sync/Syncable.swift index ba022ed68..cb9a5d5e9 100644 --- a/Parra/Managers/Sync/Syncable.swift +++ b/Parra/Managers/Sync/Syncable.swift @@ -9,6 +9,6 @@ import Foundation internal protocol Syncable { - func hasDataToSync() async -> Bool + func hasDataToSync(since date: Date?) async -> Bool func synchronizeData() async throws } diff --git a/Parra/Parra+Analytics.swift b/Parra/Parra+Analytics.swift deleted file mode 100644 index 2592b6f9a..000000000 --- a/Parra/Parra+Analytics.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// Parra+Analytics.swift -// Parra -// -// Created by Mick MacCallum on 12/29/22. -// Copyright © 2022 Parra, Inc. All rights reserved. -// - -import Foundation - -public extension Parra { - /// Logs a new event to the user's current session in Parra Analytics. Events can be used to activate campaigns configured in the Parra dashboard. - static func logAnalyticsEvent(_ name: ParraSessionNamedEvent, - params: [Key: Any] = [String: Any]()) where Key: CustomStringConvertible { - Task { - await shared.sessionManager.logEvent(name.eventName, params: params) - } - } - - /// Logs a new event to the user's current session in Parra Analytics. Events can be used to activate campaigns configured in the Parra dashboard. - static func logAnalyticsEvent(_ name: String, - params: [Key: Any] = [String: Any]()) where Key: CustomStringConvertible { - Task { - await shared.sessionManager.logEvent(name, params: params) - } - } - - /// Logs a new event to the user's current session in Parra Analytics. Events can be used to activate campaigns configured in the Parra dashboard. - static func logAnalyticsEvent(_ name: Name, - params: [Key: Any] = [String: Any]()) where Key: CustomStringConvertible, Name: RawRepresentable, Name.RawValue == String { - Task { - await shared.sessionManager.logEvent(name.rawValue, params: params) - } - } - - /// Logs a new event to the user's current session in Parra Analytics. Events can be used to activate campaigns configured in the Parra dashboard. - static func logAnalyticsEvent(_ name: Name, - params: Encodable) where Name: RawRepresentable, Name.RawValue == String { - Task { - do { - let paramsDict = try params.asDictionary() - - await shared.sessionManager.logEvent(name.rawValue, params: paramsDict) - } catch let error { - parraLogError("Error logging analytics event. Encodable params failed to encode as JSON.", error) - } - } - } - - /// Attaches a property to the current user, as defined by the Parra authentication handler. User properties can be used to activate campaigns - /// configured in the Parra dashboard. - static func setUserProperty(_ value: Any, - forKey key: Key) where Key: CustomStringConvertible { - Task { - await shared.sessionManager.setUserProperty(value, forKey: key) - } - } -} diff --git a/Parra/Parra+Endpoints.swift b/Parra/Parra+Endpoints.swift deleted file mode 100644 index a5f263fb0..000000000 --- a/Parra/Parra+Endpoints.swift +++ /dev/null @@ -1,221 +0,0 @@ -// -// Parra+Endpoints.swift -// Parra -// -// Created by Mick MacCallum on 3/12/22. -// - -import UIKit - -internal extension Parra { - // MARK: - API - enum API { - // MARK: - Feedback API - internal enum Feedback { - /// Fetches all available Parra Feedback cards for the supplied app area. If `ParraQuestionAppArea.all` is provided - /// cards from all app areas will be combined and returned. - internal static func getCards(appArea: ParraQuestionAppArea) async throws -> [ParraCardItem] { - var queryItems: [String: String] = [:] - // It is important that an app area name is only provided if a specific one is meant to be returned. - // If the app area type is `all` then there should not be a `app_area` key present in the request. - if let appAreaName = appArea.parameterized { - queryItems["app_area_id"] = appAreaName - } - - let response: AuthenticatedRequestResult = await Parra.shared.networkManager.performAuthenticatedRequest( - endpoint: .getCards, - queryItems: queryItems, - cachePolicy: .reloadIgnoringLocalAndRemoteCacheData - ) - - switch response.result { - case .success(let cardsResponse): - return cardsResponse.items - case .failure(let error): - parraLogError("Error fetching cards from Parra", error) - - throw error - } - } - - /// Submits the provided list of CompleteCards as answers to the cards linked to them. - internal static func bulkAnswerQuestions(cards: [CompletedCard]) async throws { - if cards.isEmpty { - return - } - - let response: AuthenticatedRequestResult = await Parra.shared.networkManager.performAuthenticatedRequest( - endpoint: .postBulkAnswerQuestions, - cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, - body: cards.map { CompletedCardUpload(completedCard: $0) } - ) - - switch response.result { - case .success: - return - case .failure(let error): - parraLogError("Error submitting card responses to Parra", error) - - throw error - } - } - - /// Fetches the feedback form with the provided id from the Parra API. - internal static func getFeedbackForm(with formId: String) async throws -> ParraFeedbackFormResponse { - guard let escapedFormId = formId.addingPercentEncoding( - withAllowedCharacters: .urlPathAllowed - ) else { - throw ParraError.message("Provided form id: \(formId) contains invalid characters. Must be URL path encodable.") - } - - let response: AuthenticatedRequestResult = await Parra.shared.networkManager.performAuthenticatedRequest( - endpoint: .getFeedbackForm(formId: escapedFormId) - ) - - switch response.result { - case .success(let formResponse): - return formResponse - case .failure(let error): - parraLogError("Error fetching feedback form from Parra", error) - - throw error - } - } - - /// Submits the provided data as answers for the form with the provided id. - internal static func submitFeedbackForm( - with formId: String, - data: [FeedbackFormField: String] - ) async throws { - // Map of FeedbackFormField.name -> a String (or value if applicable) - let body = data.reduce([String: String]()) { partialResult, entry in - var next = partialResult - next[entry.key.name] = entry.value - return next - } - - guard let escapedFormId = formId.addingPercentEncoding( - withAllowedCharacters: .urlPathAllowed - ) else { - throw ParraError.message("Provided form id: \(formId) contains invalid characters. Must be URL path encodable.") - } - - let response: AuthenticatedRequestResult = await Parra.shared.networkManager.performAuthenticatedRequest( - endpoint: .postSubmitFeedbackForm(formId: escapedFormId), - body: body - ) - - switch response.result { - case .success: - return - case .failure(let error): - parraLogError("Error submitting form to Parra", error) - - throw error - } - } - } - - // MARK: - Sessions API - internal enum Sessions { - /// Uploads the provided sessions, failing outright if enough sessions fail to upload - /// - Returns: A set of ids of the sessions that were successfully uploaded. - @MainActor - static func bulkSubmitSessions(sessions: [ParraSession]) async throws -> (Set, ParraSessionsResponse?) { - guard let tenantId = await ParraConfigState.shared.getCurrentState().tenantId else { - throw ParraError.notInitialized - } - - if sessions.isEmpty { - return ([], nil) - } - - var completedSessions = Set() - // It's possible that multiple sessions that are uploaded could receive a response indicating that polling - // should occur. If this happens, we'll honor the most recent of these. - var sessionResponse: ParraSessionsResponse? - for session in sessions { - parraLogDebug("Uploading session: \(session.sessionId)") - - let sessionUpload = ParraSessionUpload(session: session) - - let response: AuthenticatedRequestResult = await Parra.shared.networkManager.performAuthenticatedRequest( - endpoint: .postBulkSubmitSessions(tenantId: tenantId), - config: .defaultWithRetries, - body: sessionUpload - ) - - switch response.result { - case .success(let payload): - // Don't override the session response unless it's another one with shouldPoll enabled. - if payload.shouldPoll { - sessionResponse = payload - } - - completedSessions.insert(session.sessionId) - case .failure(let error): - parraLogError(error) - - // If any of the sessions fail to upload afty rerying, fail the entire operation - // returning the sessions that have been completed so far. - if response.attributes.contains(.exceededRetryLimit) { - return (completedSessions, sessionResponse) - } - } - } - - return (completedSessions, sessionResponse) - } - } - - // MARK: - Push API - internal enum Push { - @MainActor - static func uploadPushToken(token: String) async throws { - guard let tenantId = await ParraConfigState.shared.getCurrentState().tenantId else { - throw ParraError.notInitialized - } - - let body: [String: String] = [ - "token": token, - "type": "apns" - ] - - let response: AuthenticatedRequestResult = await Parra.shared.networkManager.performAuthenticatedRequest( - endpoint: .postPushTokens(tenantId: tenantId), - body: body - ) - - switch response.result { - case .success: - return - case .failure(let error): - parraLogError("Error uploading device push token to Parra", error) - - throw error - } - } - } - - // MARK: - Assets API - internal enum Assets { - internal static func performBulkAssetCachingRequest(assets: [Asset]) async { - await Parra.shared.networkManager.performBulkAssetCachingRequest( - assets: assets - ) - } - - internal static func fetchAsset(asset: Asset) async throws -> UIImage? { - return try await Parra.shared.networkManager.fetchAsset( - asset: asset - ) - } - - internal static func isAssetCached(asset: Asset) async -> Bool { - return await Parra.shared.networkManager.isAssetCached( - asset: asset - ) - } - } - } -} diff --git a/Parra/Parra+SyncEvents.swift b/Parra/Parra+SyncEvents.swift deleted file mode 100644 index e30c4dc3e..000000000 --- a/Parra/Parra+SyncEvents.swift +++ /dev/null @@ -1,154 +0,0 @@ -// -// Parra+SyncEvents.swift -// Parra -// -// Created by Michael MacCallum on 3/5/22. -// - -import Foundation -import UIKit - -internal extension Parra { - private static var backgroundTaskId: UIBackgroundTaskIdentifier? - private static var hasStartedEventObservers = false - - func addEventObservers() { - guard NSClassFromString("XCTestCase") == nil && !Parra.hasStartedEventObservers else { - return - } - - Parra.hasStartedEventObservers = true - - notificationCenter.addObserver( - self, - selector: #selector(self.applicationDidBecomeActive), - name: UIApplication.didBecomeActiveNotification, - object: nil - ) - - notificationCenter.addObserver( - self, - selector: #selector(self.applicationWillResignActive), - name: UIApplication.willResignActiveNotification, - object: nil - ) - - notificationCenter.addObserver( - self, - - selector: #selector(self.applicationDidEnterBackground), - name: UIApplication.didEnterBackgroundNotification, - object: nil - ) - - notificationCenter.addObserver( - self, - selector: #selector(self.triggerEventualSyncFromNotification), - name: UIApplication.significantTimeChangeNotification, - object: nil - ) - } - - func removeEventObservers() { - guard NSClassFromString("XCTestCase") == nil else { - return - } - - NotificationCenter.default.removeObserver( - self, - name: UIApplication.didBecomeActiveNotification, - object: nil - ) - NotificationCenter.default.removeObserver( - self, - name: UIApplication.willResignActiveNotification, - object: nil - ) - NotificationCenter.default.removeObserver( - self, - name: UIApplication.didEnterBackgroundNotification, - object: nil - ) - NotificationCenter.default.removeObserver( - self, - name: UIApplication.significantTimeChangeNotification, - object: nil - ) - } - - @MainActor - @objc func applicationDidBecomeActive(notification: Notification) { - if let taskId = Parra.backgroundTaskId, - let app = notification.object as? UIApplication { - - app.endBackgroundTask(taskId) - } - - Parra.logAnalyticsEvent(ParraSessionEventType._Internal.appState(state: .active)) - - triggerEventualSyncFromNotification(notification: notification) - } - - @MainActor - @objc func applicationWillResignActive(notification: Notification) { - Parra.logAnalyticsEvent(ParraSessionEventType._Internal.appState(state: .inactive)) - - triggerSyncFromNotification(notification: notification) - - let endSession = { - guard let taskId = Parra.backgroundTaskId else { - return - } - - Parra.backgroundTaskId = nil - - parraLogDebug("Background task: \(taskId) triggering session end") - - await Parra.shared.sessionManager.endSession() - - UIApplication.shared.endBackgroundTask(taskId) - } - - Parra.backgroundTaskId = UIApplication.shared.beginBackgroundTask( - withName: InternalConstants.backgroundTaskName - ) { - parraLogDebug("Background task expiration handler invoked") - - Task { @MainActor in - await endSession() - } - } - - let startTime = Date() - Task(priority: .background) { - while Date().timeIntervalSince(startTime) < InternalConstants.backgroundTaskDuration { - try await Task.sleep(nanoseconds: 100_000_000) - } - - parraLogDebug("Ending Parra background execution after \(InternalConstants.backgroundTaskDuration)s") - - Task { @MainActor in - await endSession() - } - } - } - - @MainActor - @objc func applicationDidEnterBackground(notification: Notification) { - Parra.logAnalyticsEvent(ParraSessionEventType._Internal.appState(state: .background)) - } - - @MainActor - @objc private func triggerSyncFromNotification(notification: Notification) { - Task { - await syncManager.enqueueSync(with: .immediate) - } - } - - @MainActor - @objc private func triggerEventualSyncFromNotification(notification: Notification) { - Task { - await syncManager.enqueueSync(with: .eventual) - } - } -} diff --git a/Parra/Parra.swift b/Parra/Parra.swift deleted file mode 100644 index 6348edd5b..000000000 --- a/Parra/Parra.swift +++ /dev/null @@ -1,148 +0,0 @@ -// -// Parra.swift -// Parra -// -// Created by Michael MacCallum on 11/22/21. -// - -import Foundation -import UIKit - -/// The Parra module is primarily used for authenticating with the Parra API. For usage beyond this, you'll need -/// to install and use other Parra libraries. -public class Parra: ParraModule { - internal static private(set) var name = "core" - - @MainActor - internal static var shared: Parra! = { - let diskCacheURL = ParraDataManager.Path.networkCachesDirectory - // Cache may reject image entries if they are greater than 10% of the cache's size - // so these need to reflect that. - let cache = URLCache( - memoryCapacity: 50 * 1024 * 1024, - diskCapacity: 300 * 1024 * 1024, - directory: diskCacheURL - ) - - let configuration = URLSessionConfiguration.default - configuration.urlCache = cache - configuration.requestCachePolicy = .returnCacheDataElseLoad - - let notificationCenter = ParraNotificationCenter.default - let urlSession = URLSession(configuration: configuration) - let dataManager = ParraDataManager() - - let networkManager = ParraNetworkManager( - dataManager: dataManager, - urlSession: urlSession - ) - - let sessionManager = ParraSessionManager( - dataManager: dataManager, - networkManager: networkManager - ) - - let syncManager = ParraSyncManager( - networkManager: networkManager, - sessionManager: sessionManager, - notificationCenter: notificationCenter - ) - - return Parra( - dataManager: dataManager, - syncManager: syncManager, - sessionManager: sessionManager, - networkManager: networkManager, - notificationCenter: notificationCenter - ) - }() - - internal let dataManager: ParraDataManager - internal let syncManager: ParraSyncManager - internal let sessionManager: ParraSessionManager - internal let networkManager: ParraNetworkManager - internal let notificationCenter: NotificationCenterType - - internal init( - dataManager: ParraDataManager, - syncManager: ParraSyncManager, - sessionManager: ParraSessionManager, - networkManager: ParraNetworkManager, - notificationCenter: NotificationCenterType - ) { - self.dataManager = dataManager - self.syncManager = syncManager - self.sessionManager = sessionManager - self.networkManager = networkManager - self.notificationCenter = notificationCenter - - UIFont.registerFontsIfNeeded() // Needs to be called before any UI is displayed. - } - - deinit { - // Being a good citizen. This should only happen when the singleton is destroyed when the - // app is being killed anyway. - removeEventObservers() - } - - // MARK: - Authentication - - /// Used to clear any cached credentials for the current user. After calling logout, the authentication provider you configured - /// will be invoked the very next time the Parra API is accessed. - public static func logout(completion: (() -> Void)? = nil) { - Task { - await logout() - - DispatchQueue.main.async { - completion?() - } - } - } - - /// Used to clear any cached credentials for the current user. After calling logout, the authentication provider you configured - /// will be invoked the very next time the Parra API is accessed. - public static func logout() async { - await shared.syncManager.enqueueSync(with: .immediate) - await shared.dataManager.updateCredential(credential: nil) - await shared.syncManager.stopSyncTimer() - } - - // MARK: - Synchronization - - /// Uploads any cached Parra data. This includes data like answers to questions. - public static func triggerSync(completion: (() -> Void)? = nil) { - Task { - await triggerSync() - - completion?() - } - } - - /// Parra data is syncrhonized automatically. Use this method if you wish to trigger a synchronization event manually. - /// This may be something you want to do in response to a significant event in your app, or in response to a low memory - /// warning, for example. Note that in order to prevent excessive network activity it may take up to 30 seconds for the sync - /// to complete after being initiated. - public static func triggerSync() async { - await shared.triggerSync() - } - - /// Uploads any cached Parra data. This includes data like answers to questions. - internal func triggerSync() async { - // Don't expose sync mode publically. - await syncManager.enqueueSync(with: .eventual) - } - - internal func hasDataToSync() async -> Bool { - return await sessionManager.hasDataToSync() - } - - internal func synchronizeData() async { - guard let response = await sessionManager.synchronizeData() else { - return - } - - for module in (await ParraGlobalState.shared.getAllRegisteredModules()).values { - module.didReceiveSessionResponse(sessionResponse: response) - } - } -} diff --git a/Parra/ParraError.swift b/Parra/ParraError.swift deleted file mode 100644 index 3b73b5031..000000000 --- a/Parra/ParraError.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// ParraError.swift -// Parra -// -// Created by Michael MacCallum on 2/26/22. -// - -import Foundation - -public enum ParraError: LocalizedError, CustomStringConvertible { - case message(String) - case custom(String, Error?) - case notInitialized - case missingAuthentication - case authenticationFailed(String) - case networkError(status: Int, message: String, request: URLRequest) - case jsonError(String) - case unknown - - public var errorDescription: String { - switch self { - case .message(let message): - return message - case .custom(let message, let error): - if let error { - return "\(message) Error: \(error)" - } - - return message - case .notInitialized: - return "Parra has not been initialized. Call Parra.initialize() in applicationDidFinishLaunchingWithOptions" - case .missingAuthentication: - return "An authentication provider has not been set. Add Parra.initialize() to your applicationDidFinishLaunchingWithOptions method." - case .authenticationFailed(let error): - return "Invoking the authentication provider passed to Parra.initialize() failed with error: \(error)" - case .networkError(let status, let error, let request): - return "A network error occurred performing request: \(request)\nReceived status code: \(status) with error: \(error)" - case .jsonError(let error): - return "An error occurred decoding JSON. Error: \(error)" - case .unknown: - return "An unknown error occurred." - } - } - - public var description: String { - errorDescription - } -} diff --git a/Parra/PersistentStorage/CredentialStorage.swift b/Parra/PersistentStorage/CredentialStorage.swift index 190495ca7..7202c6fcd 100644 --- a/Parra/PersistentStorage/CredentialStorage.swift +++ b/Parra/PersistentStorage/CredentialStorage.swift @@ -7,6 +7,8 @@ import Foundation +fileprivate let logger = Logger(category: "Credential storage") + internal actor CredentialStorage: ItemStorage { internal enum Key { static let currentUser = "current_user_credential" @@ -21,7 +23,11 @@ internal actor CredentialStorage: ItemStorage { } func updateCredential(credential: ParraCredential?) async { - try? await storageModule.write(name: Key.currentUser, value: credential) + do { + try await storageModule.write(name: Key.currentUser, value: credential) + } catch let error { + logger.error("error updating credential", error) + } } func currentCredential() async -> ParraCredential? { diff --git a/Parra/PersistentStorage/DataStorageMedium.swift b/Parra/PersistentStorage/DataStorageMedium.swift index bab7de581..c2ad29276 100644 --- a/Parra/PersistentStorage/DataStorageMedium.swift +++ b/Parra/PersistentStorage/DataStorageMedium.swift @@ -7,8 +7,14 @@ import Foundation -public enum DataStorageMedium { +internal enum DataStorageMedium { case memory - case fileSystem(folder: String, fileName: String, storeItemsSeparately: Bool) + case fileSystem( + baseUrl: URL, + folder: String, + fileName: String, + storeItemsSeparately: Bool, + fileManager: FileManager + ) case userDefaults(key: String) } diff --git a/Parra/PersistentStorage/ItemStorage.swift b/Parra/PersistentStorage/ItemStorage.swift index 15883ca57..5f123a559 100644 --- a/Parra/PersistentStorage/ItemStorage.swift +++ b/Parra/PersistentStorage/ItemStorage.swift @@ -7,7 +7,7 @@ import Foundation -public protocol ItemStorage { +internal protocol ItemStorage { associatedtype DataType: Codable init(storageModule: ParraStorageModule) diff --git a/Parra/PersistentStorage/ParraStorageModule.swift b/Parra/PersistentStorage/ParraStorageModule.swift index a59a87112..71702a54a 100644 --- a/Parra/PersistentStorage/ParraStorageModule.swift +++ b/Parra/PersistentStorage/ParraStorageModule.swift @@ -7,12 +7,12 @@ import Foundation -public actor ParraStorageModule { +internal actor ParraStorageModule { // Whether or not data has previously been loaded from disk. internal private(set) var isLoaded = false internal let dataStorageMedium: DataStorageMedium internal let persistentStorage: (medium: PersistentStorageMedium, key: String)? - internal private(set) var storageCache: [String: DataType] = [:] + internal private(set) var storageCache: [String : DataType] = [:] internal var description: String { return """ @@ -31,25 +31,27 @@ public actor ParraStorageModule { switch dataStorageMedium { case .memory, .userDefaults(_): return false - case .fileSystem(_, _, let storeItemsSeparately): + case .fileSystem(_, _, _, let storeItemsSeparately, _): return storeItemsSeparately } } - public init(dataStorageMedium: DataStorageMedium) { + internal init( + dataStorageMedium: DataStorageMedium, + jsonEncoder: JSONEncoder, + jsonDecoder: JSONDecoder + ) { self.dataStorageMedium = dataStorageMedium switch dataStorageMedium { case .memory: self.persistentStorage = nil - case .fileSystem(folder: let folder, fileName: let fileName, _): - let baseUrl = ParraDataManager.Path.parraDirectory - .safeAppendDirectory(folder) - + case .fileSystem(let baseUrl, let folder, let fileName, _, let fileManager): let fileSystemStorage = FileSystemStorage( - baseUrl: baseUrl, - jsonEncoder: .parraEncoder, - jsonDecoder: .parraDecoder + baseUrl: baseUrl.appendDirectory(folder), + jsonEncoder: jsonEncoder, + jsonDecoder: jsonDecoder, + fileManager: fileManager ) self.persistentStorage = (fileSystemStorage, fileName) @@ -58,8 +60,8 @@ public actor ParraStorageModule { let userDefaultsStorage = UserDefaultsStorage( userDefaults: userDefaults, - jsonEncoder: .parraEncoder, - jsonDecoder: .parraDecoder + jsonEncoder: jsonEncoder, + jsonDecoder: jsonDecoder ) self.persistentStorage = (userDefaultsStorage, key) @@ -79,15 +81,21 @@ public actor ParraStorageModule { if let fileSystem = persistentStorage.medium as? FileSystemStorage, storeItemsSeparately { storageCache = await fileSystem.readAllInDirectory() } else { - if let existingData: [String: DataType] = try? await persistentStorage.medium.read( - name: persistentStorage.key - ) { - storageCache = existingData + do { + if let existingData: [String : DataType] = try await persistentStorage.medium.read( + name: persistentStorage.key + ) { + storageCache = existingData + } + } catch let error { + Logger.error("Error loading data from persistent storage", error, [ + "key": persistentStorage.key + ]) } } } - public func currentData() async -> [String: DataType] { + internal func currentData() async -> [String : DataType] { if !isLoaded { await loadData() } @@ -95,7 +103,7 @@ public actor ParraStorageModule { return storageCache } - public func read(name: String) async -> DataType? { + internal func read(name: String) async -> DataType? { if !isLoaded { await loadData() } @@ -105,19 +113,27 @@ public actor ParraStorageModule { } if let persistentStorage, storeItemsSeparately { - if let loadedData: [String: DataType] = try? await persistentStorage.medium.read(name: name) { - storageCache.merge(loadedData) { (_, new) in new } - return storageCache[name] + do { + if let loadedData: [String : DataType] = try await persistentStorage.medium.read(name: name) { + storageCache.merge(loadedData) { (_, new) in new } + + return storageCache[name] + } + } catch let error { + Logger.error("Error reading data from persistent storage", error, [ + "key": persistentStorage.key + ]) } } return nil } - public func write(name: String, - value: DataType?) async throws { - + internal func write( + name: String, + value: DataType? + ) async throws { if !isLoaded { await loadData() } @@ -147,7 +163,7 @@ public actor ParraStorageModule { } } - public func delete(name: String) async { + internal func delete(name: String) async { if !isLoaded { await loadData() } @@ -170,11 +186,11 @@ public actor ParraStorageModule { ) } } catch let error { - parraLogError("ParraStorageModule error deleting file", error) + Logger.error("ParraStorageModule error deleting file", error) } } - public func clear() async { + internal func clear() async { defer { storageCache.removeAll() } @@ -183,16 +199,22 @@ public actor ParraStorageModule { return } - if storeItemsSeparately { - for (name, _) in storageCache { - try? await persistentStorage.medium.delete( - name: name + do { + if storeItemsSeparately { + for (name, _) in storageCache { + try await persistentStorage.medium.delete( + name: name + ) + } + } else { + try await persistentStorage.medium.delete( + name: persistentStorage.key ) } - } else { - try? await persistentStorage.medium.delete( - name: persistentStorage.key - ) + } catch let error { + Logger.error("Error deleting data from persistent storage", error, [ + "key": persistentStorage.key + ]) } } } diff --git a/Parra/PersistentStorage/PersistentStorageMedium/FileSystemStorage.swift b/Parra/PersistentStorage/PersistentStorageMedium/FileSystemStorage.swift index f31bad608..fdee6aff4 100644 --- a/Parra/PersistentStorage/PersistentStorageMedium/FileSystemStorage.swift +++ b/Parra/PersistentStorage/PersistentStorageMedium/FileSystemStorage.swift @@ -7,65 +7,75 @@ import Foundation +fileprivate let logger = Logger(category: "File system storage medium") + internal actor FileSystemStorage: PersistentStorageMedium { - private let fileManager = FileManager.default + private let baseUrl: URL private let jsonEncoder: JSONEncoder private let jsonDecoder: JSONDecoder - private let baseUrl: URL - + private let fileManager: FileManager + internal init( baseUrl: URL, jsonEncoder: JSONEncoder, - jsonDecoder: JSONDecoder + jsonDecoder: JSONDecoder, + fileManager: FileManager ) { + self.baseUrl = baseUrl self.jsonEncoder = jsonEncoder self.jsonDecoder = jsonDecoder - self.baseUrl = baseUrl + self.fileManager = fileManager - parraLogTrace("FileSystemStorage init with baseUrl: \(baseUrl.safeNonEncodedPath())") + logger.trace("FileSystemStorage init with baseUrl: \(baseUrl.privateRelativePath())") // TODO: Think about this more later, but I think this is a fatalError() - try? fileManager.safeCreateDirectory(at: baseUrl) + do { + try fileManager.safeCreateDirectory(at: baseUrl) + } catch let error { + logger.error("Error creating directory", error, [ + "path": baseUrl.absoluteString + ]) + } } internal func read(name: String) async throws -> T? where T: Codable { - let file = baseUrl.safeAppendPathComponent(name) - guard let data = try? Data(contentsOf: file) else { - return nil + let file = baseUrl.appendFilename(name) + if let data = try? Data(contentsOf: file) { + return try jsonDecoder.decode(T.self, from: data) } - - return try jsonDecoder.decode(T.self, from: data) + + return nil } - internal func readAllInDirectory() async -> [String: T] where T: Codable { + internal func readAllInDirectory() async -> [String : T] where T: Codable { guard let enumerator = fileManager.enumerator( - atPath: baseUrl.safeNonEncodedPath() + atPath: baseUrl.nonEncodedPath() ) else { return [:] } - let result = enumerator.reduce([String: T]()) { [weak fileManager] partialResult, element in + let result = enumerator.reduce([String : T]()) { [weak fileManager] partialResult, element in var accumulator = partialResult guard let fileManager, let fileName = element as? String else { return accumulator } - parraLogTrace("readAllInDirectory reading file: \(fileName)") + logger.trace("readAllInDirectory reading file: \(fileName)") - let path = baseUrl.safeAppendPathComponent(fileName) + let path = baseUrl.appendFilename(fileName) var isDirectory: ObjCBool = false let exists = fileManager.fileExists( - atPath: path.safeNonEncodedPath(), + atPath: path.nonEncodedPath(), isDirectory: &isDirectory ) if !exists || isDirectory.boolValue || fileName.starts(with: ".") { - parraLogTrace("readAllInDirectory skipping file: \(fileName) - is likely hidden or a directory") + logger.trace("readAllInDirectory skipping file: \(fileName) - is likely hidden or a directory") return accumulator } - parraLogTrace("readAllInDirectory file: \(fileName) exists and is not hidden or a directory") + logger.trace("readAllInDirectory file: \(fileName) exists and is not hidden or a directory") do { let data = try Data(contentsOf: path) @@ -73,30 +83,30 @@ internal actor FileSystemStorage: PersistentStorageMedium { accumulator[fileName] = next - parraLogTrace("readAllInDirectory reading file: \(fileName) into cache") + logger.trace("readAllInDirectory reading file: \(fileName) into cache") } catch let error { - parraLogError("readAllInDirectory", error) + logger.error("readAllInDirectory", error) } return accumulator } - parraLogDebug("readAllInDirectory read \(result.count) item(s) into cache") + logger.debug("readAllInDirectory read \(result.count) item(s) into cache") return result } internal func write(name: String, value: T) async throws where T: Codable { - let file = baseUrl.safeAppendPathComponent(name) + let file = baseUrl.appendFilename(name) let data = try jsonEncoder.encode(value) try data.write(to: file, options: .atomic) } internal func delete(name: String) async throws { - let url = baseUrl.safeAppendPathComponent(name) + let url = baseUrl.appendFilename(name) - if fileManager.safeFileExists(at: url) { + if try fileManager.safeFileExists(at: url) { try fileManager.removeItem(at: url) } } diff --git a/Parra/PersistentStorage/SessionStorage.swift b/Parra/PersistentStorage/SessionStorage.swift deleted file mode 100644 index ea555682d..000000000 --- a/Parra/PersistentStorage/SessionStorage.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// SessionStorage.swift -// Parra -// -// Created by Mick MacCallum on 11/20/22. -// Copyright © 2022 Parra, Inc. All rights reserved. -// - -import Foundation - -internal actor SessionStorage: ItemStorage { - typealias DataType = ParraSession - - let storageModule: ParraStorageModule - - init(storageModule: ParraStorageModule) { - self.storageModule = storageModule - } - - func update(session: ParraSession) async { - do { - try await storageModule.write( - name: session.sessionId, - value: session - ) - } catch let error { - parraLogError("Error storing session", error, [ - "sessionId": session.sessionId - ]) - } - } - - func deleteSessions(with sessionIds: Set) async { - for sessionId in sessionIds { - await storageModule.delete(name: sessionId) - } - } - - func allTrackedSessions() async -> [ParraSession] { - let sessions = await storageModule.currentData() - - return sessions.sorted { (first, second) in - let firstKey = Float(first.key) ?? 0 - let secondKey = Float(second.key) ?? 0 - - return firstKey < secondKey - }.map { (key: String, value: ParraSession) in - return value - } - } - - func numberOfTrackedSessions() async -> Int { - let sessions = await storageModule.currentData() - - return sessions.count - } -} diff --git a/Parra/PersistentStorage/Sessions/FileHandleType.swift b/Parra/PersistentStorage/Sessions/FileHandleType.swift new file mode 100644 index 000000000..f5ceda640 --- /dev/null +++ b/Parra/PersistentStorage/Sessions/FileHandleType.swift @@ -0,0 +1,14 @@ +// +// FileHandleType.swift +// Parra +// +// Created by Mick MacCallum on 8/27/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal enum FileHandleType { + case session + case events +} diff --git a/Parra/PersistentStorage/Sessions/Generators/ParraSessionGenerator.swift b/Parra/PersistentStorage/Sessions/Generators/ParraSessionGenerator.swift new file mode 100644 index 000000000..4ed8b7cc8 --- /dev/null +++ b/Parra/PersistentStorage/Sessions/Generators/ParraSessionGenerator.swift @@ -0,0 +1,78 @@ +// +// ParraSessionGenerator.swift +// Parra +// +// Created by Mick MacCallum on 8/31/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +fileprivate let logger = Logger( + bypassEventCreation: true, + category: "Session Generator" +) + +internal enum ParraSessionGeneratorElement { + case success(sessionDirectory: URL, session: ParraSession) + case error(sessionDirectory: URL, error: ParraError) +} + +internal struct ParraSessionGenerator: ParraSessionGeneratorType, Sequence, IteratorProtocol { + // Type is optional. We have to be able to filter out elements while doing the lazy enumeration + // so we need a way to indicate to the caller that the item produced by a given iteration can + // be skipped, whichout returning nil and ending the Sequence. We use a double Optional for this. + typealias Element = ParraSessionGeneratorElement + + private let directoryEnumerator: FileManager.DirectoryEnumerator + + let sessionJsonDecoder: JSONDecoder + let eventJsonDecoder: JSONDecoder + let fileManager: FileManager + + internal init( + forSessionsAt path: URL, + sessionJsonDecoder: JSONDecoder, + eventJsonDecoder: JSONDecoder, + fileManager: FileManager + ) throws { + self.sessionJsonDecoder = sessionJsonDecoder + self.eventJsonDecoder = eventJsonDecoder + self.fileManager = fileManager + self.directoryEnumerator = try Self.directoryEnumerator( + forSessionsAt: path, + with: fileManager + ) + } + + mutating func next() -> ParraSessionGeneratorElement? { + return logger.withScope { (logger) -> ParraSessionGeneratorElement? in + guard let element = produceNextSessionPaths( + from: directoryEnumerator, + type: ParraSession.self + ) else { + return nil + } + + switch element { + case .success(let sessionDirectory, let sessionPath, _): + do { + return .success( + sessionDirectory: sessionDirectory, + session: try readSessionSync(at: sessionPath) + ) + } catch let error { + return .error( + sessionDirectory: sessionDirectory, + error: .generic("Error reading session", error) + ) + } + case .error(let sessionDirectory, let error): + return .error( + sessionDirectory: sessionDirectory, + error: error + ) + } + } + } +} diff --git a/Parra/PersistentStorage/Sessions/Generators/ParraSessionGeneratorType.swift b/Parra/PersistentStorage/Sessions/Generators/ParraSessionGeneratorType.swift new file mode 100644 index 000000000..0fef4fc88 --- /dev/null +++ b/Parra/PersistentStorage/Sessions/Generators/ParraSessionGeneratorType.swift @@ -0,0 +1,156 @@ +// +// ParraSessionGeneratorType.swift +// Parra +// +// Created by Mick MacCallum on 8/31/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +fileprivate let logger = Logger( + bypassEventCreation: true, + category: "Session Generator Helpers" +) + +internal enum ParraSessionGeneratorTypeElement { + case success(sessionDirectory: URL, sessionPath: URL, eventsUrl: URL) + case error(sessionDirectory: URL, error: ParraError) +} + +internal protocol ParraSessionGeneratorType { + var sessionJsonDecoder: JSONDecoder { get } + var eventJsonDecoder: JSONDecoder { get } + var fileManager: FileManager { get } +} + +internal extension ParraSessionGeneratorType where Self: AsyncSequence { + func makeAsyncIterator() -> Self { + self + } +} + +internal extension ParraSessionGeneratorType { + static func directoryEnumerator( + forSessionsAt path: URL, + with fileManager: FileManager + ) throws -> FileManager.DirectoryEnumerator { + guard let directoryEnumerator = fileManager.enumerator( + at: path, + includingPropertiesForKeys: [], + options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants] + ) else { + throw ParraError.fileSystem( + path: path, + message: "Failed to create file enumerator" + ) + } + + return directoryEnumerator + } + + func produceNextSessionPaths( + from directoryEnumerator: FileManager.DirectoryEnumerator, + type: T.Type + ) -> ParraSessionGeneratorTypeElement? { + return logger.withScope { logger in + guard let nextSessionUrl = directoryEnumerator.nextObject() as? URL else { + logger.debug("next session url couldn't be produced") + + return nil + } + + let ext = ParraSession.Constant.packageExtension + if !nextSessionUrl.hasDirectoryPath || nextSessionUrl.pathExtension != ext { + logger.debug("session path was unexpected file type. skipping.") + + // This is a case where we're indicating an invalid item was produced, and it is necessary to skip it. + return .error( + sessionDirectory: nextSessionUrl, + error: .fileSystem( + path: nextSessionUrl, + message: "Session directory was not valid." + ) + ) + } + + logger.trace("combining data for session: \(nextSessionUrl.lastPathComponent)") + + let (sessionPath, eventsUrl) = SessionReader.sessionPaths( + in: nextSessionUrl + ) + + return .success( + sessionDirectory: nextSessionUrl, + sessionPath: sessionPath, + eventsUrl: eventsUrl + ) + } + } + + func readSessionSync(at path: URL) throws -> ParraSession { + let fileHandle = try FileHandle(forReadingFrom: path) + + defer { + do { + try fileHandle.close() + } catch let error { + logger.error("Error closing session reader file handle", error) + } + } + + try fileHandle.seek(toOffset: 0) + + let sessionData: Data? + do { + sessionData = try fileHandle.readToEnd() + } catch let error { + throw ParraError.fileSystem( + path: path, + message: "Couldn't read session data. \(error.localizedDescription)" + ) + } + + guard let sessionData else { + throw ParraError.fileSystem( + path: path, + message: "Session file was empty." + ) + } + + return try sessionJsonDecoder.decode( + ParraSession.self, + from: sessionData + ) + } + + func readEvents(at path: URL) async throws -> [ParraSessionEvent] { + var events = [ParraSessionEvent]() + let fileHandle = try FileHandle(forReadingFrom: path) + + defer { + do { + try fileHandle.close() + } catch let error { + logger.error("Error closing event reader file handle", error) + } + } + + for try await eventString in fileHandle.bytes.lines { + // As every row is parsed, place it in an events array. This will save us have two copies + // of every event in memory at the same time. + try autoreleasepool { + if let eventData = eventString.data(using: .utf8) { + events.append( + try eventJsonDecoder.decode( + ParraSessionEvent.self, + from: eventData + ) + ) + } + } + } + + return events + } +} diff --git a/Parra/PersistentStorage/Sessions/Generators/ParraSessionUploadGenerator.swift b/Parra/PersistentStorage/Sessions/Generators/ParraSessionUploadGenerator.swift new file mode 100644 index 000000000..ef1cff444 --- /dev/null +++ b/Parra/PersistentStorage/Sessions/Generators/ParraSessionUploadGenerator.swift @@ -0,0 +1,83 @@ +// +// ParraSessionUploadGenerator.swift +// Parra +// +// Created by Mick MacCallum on 8/13/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +fileprivate let logger = Logger(bypassEventCreation: true, category: "Session Upload Generator") + +internal enum ParraSessionUploadGeneratorElement { + case success(sessionDirectory: URL, upload: ParraSessionUpload) + case error(sessionDirectory: URL, error: ParraError) +} + +internal struct ParraSessionUploadGenerator: ParraSessionGeneratorType, AsyncSequence, AsyncIteratorProtocol { + typealias Element = ParraSessionUploadGeneratorElement + + private let directoryEnumerator: FileManager.DirectoryEnumerator + + let sessionJsonDecoder: JSONDecoder + let eventJsonDecoder: JSONDecoder + let fileManager: FileManager + + internal init( + forSessionsAt path: URL, + sessionJsonDecoder: JSONDecoder, + eventJsonDecoder: JSONDecoder, + fileManager: FileManager + ) throws { + self.sessionJsonDecoder = sessionJsonDecoder + self.eventJsonDecoder = eventJsonDecoder + self.fileManager = fileManager + self.directoryEnumerator = try Self.directoryEnumerator( + forSessionsAt: path, + with: fileManager + ) + } + + mutating func next() async -> ParraSessionUploadGeneratorElement? { + return await logger.withScope { (logger) -> ParraSessionUploadGeneratorElement? in + guard let element = produceNextSessionPaths( + from: directoryEnumerator, + type: ParraSessionUpload.self + ) else { + return nil + } + + switch element { + case .success(let sessionDirectory, let sessionPath, let eventsPath): + do { + let session = try readSessionSync(at: sessionPath) + let events = try await readEvents(at: eventsPath) + + logger.trace("Finished reading session and events", [ + "sessionId": session.sessionId, + "eventCount": events.count + ]) + + return .success( + sessionDirectory: sessionDirectory, + upload: ParraSessionUpload( + session: session, + events: events + ) + ) + } catch let error { + return .error( + sessionDirectory: sessionDirectory, + error: .generic("Error creating upload payload for session", error) + ) + } + case .error(let sessionDirectory, let error): + return .error( + sessionDirectory: sessionDirectory, + error: error + ) + } + } + } +} diff --git a/Parra/PersistentStorage/Sessions/SessionReader.swift b/Parra/PersistentStorage/Sessions/SessionReader.swift new file mode 100644 index 000000000..189485029 --- /dev/null +++ b/Parra/PersistentStorage/Sessions/SessionReader.swift @@ -0,0 +1,297 @@ +// +// SessionReader.swift +// Parra +// +// Created by Mick MacCallum on 8/14/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation +import Darwin + +fileprivate let logger = Logger(bypassEventCreation: true, category: "SessionReader") + +internal class SessionReader { + /// The directory where sessions will be stored. + internal let basePath: URL + + private let sessionJsonDecoder: JSONDecoder + private let eventJsonDecoder: JSONDecoder + private let fileManager: FileManager + + /// This should never be used directly outside this class. From the outside, a session can and never + /// should be nil. The idea is that session reads/writes will always happen on a serial queue, that + /// will automatically create the session if it does not exist and await the completion of this + /// operation. This is place where these checks will be made. + internal private(set) var _currentSessionContext: SessionStorageContext? + + internal private(set) var _sessionHandle: FileHandle? + internal private(set) var _eventsHandle: FileHandle? + + internal init( + basePath: URL, + sessionJsonDecoder: JSONDecoder, + eventJsonDecoder: JSONDecoder, + fileManager: FileManager + ) { + self.basePath = basePath + self.sessionJsonDecoder = sessionJsonDecoder + self.eventJsonDecoder = eventJsonDecoder + self.fileManager = fileManager + } + + deinit { + _currentSessionContext = nil + + do { + try _sessionHandle?.close() + try _eventsHandle?.close() + } catch let error { + logger.error( + "Error closing session/events file handles while closing session reader", + error + ) + } + + _sessionHandle = nil + _eventsHandle = nil + } + + internal func retreiveFileHandleForSessionSync( + with type: FileHandleType, + from context: SessionStorageContext + ) throws -> FileHandle { + let handle: FileHandle? + let path: URL + switch type { + case .session: + handle = _sessionHandle + path = context.sessionPath + case .events: + handle = _eventsHandle + path = context.eventsPath + } + + if let handle { + // It is possible that we could still have a reference to the file handle, but its descriptor has + // become invalid. If this happens, we need to close out the previous handle and create a new one. + if isHandleValid(handle: handle) { + return handle + } + + // Close and fall back on the new handle creation flow. + try handle.close() + } + + let newHandle = try FileHandle( + forUpdating: path + ) + + switch type { + case .session: + _sessionHandle = newHandle + case .events: + // Unlike the handle that writes to sessions, the events handle will always be used to append to + // the end of the file. So if we're creating a new one, we can seek to the end of the file now + // to prevent this from being necessary each time it is accessed. + try newHandle.seekToEnd() + _eventsHandle = newHandle + } + + return newHandle + } + + internal func getAllSessionDirectories() throws -> [URL] { + logger.trace("Getting all session directories") + + let directories = try fileManager.contentsOfDirectory( + at: basePath, + includingPropertiesForKeys: [], + options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants] + ) + + return directories.filter { directory in + return directory.hasDirectoryPath + && directory.pathExtension == ParraSession.Constant.packageExtension + } + } + + internal func generateSessionUploadsSync() throws -> ParraSessionUploadGenerator { + logger.trace("Creating session upload generator") + + return try ParraSessionUploadGenerator( + forSessionsAt: basePath, + sessionJsonDecoder: sessionJsonDecoder, + eventJsonDecoder: eventJsonDecoder, + fileManager: fileManager + ) + } + + internal func closeCurrentSessionSync() throws { + _currentSessionContext = nil + + try _sessionHandle?.close() + try _eventsHandle?.close() + + _sessionHandle = nil + _eventsHandle = nil + } + + @discardableResult + internal func loadOrCreateSessionSync( + nextSessionId: String = UUID().uuidString + ) throws -> SessionStorageContext { + // 1. If session context exists in memory, return it. + // 2. Create the paths where the new session is to be stored, using time stamps. + // 3. Check if the files already somehow exist. If they exist and are valid, use them. + // 4. Create a new session at the determined paths, overriding anything that existed. + // 5. Return the new session. + + if let _currentSessionContext { + return _currentSessionContext + } + + let nextSessionStart = Date.now + let sessionDir = sessionDirectory( + for: nextSessionId, + in: basePath + ) + + let (sessionPath, eventsPath) = SessionReader.sessionPaths( + in: sessionDir + ) + + var existingSession: ParraSession? + do { + if try fileManager.safeFileExists(at: sessionPath) { + // The existence of this file implies all necessary intermediate directories have been created. + + let sessionData = try Data(contentsOf: sessionPath) + existingSession = try sessionJsonDecoder.decode( + ParraSession.self, + from: sessionData + ) + + // It is possible that the session existed, but events hadn't been written yet, so + // this shouldn't fail if there is no file at the eventsPath. + try fileManager.safeCreateFile(at: eventsPath) + } + } catch let error { + existingSession = nil + + logger.error(error) + } + + if let existingSession { + let existingSessionContext = SessionStorageContext( + session: existingSession, + sessionPath: sessionPath, + eventsPath: eventsPath + ) + + _currentSessionContext = existingSessionContext + + return existingSessionContext + } + + try fileManager.safeCreateDirectory( + at: sessionDir + ) + try fileManager.safeCreateFile( + at: sessionPath, + overrideExisting: true + ) + try fileManager.safeCreateFile( + at: eventsPath, + overrideExisting: true + ) + + let nextSessionContext = SessionStorageContext( + session: ParraSession( + sessionId: nextSessionId, + createdAt: nextSessionStart, + sdkVersion: Parra.libraryVersion() + ), + sessionPath: sessionPath, + eventsPath: eventsPath + ) + + _currentSessionContext = nextSessionContext + + return nextSessionContext + } + + internal func updateCachedCurrentSessionSync( + to newSession: ParraSession + ) { + _currentSessionContext?.updateSession( + to: newSession + ) + } + + internal func deleteSessionSync(with id: String) throws { + let sessionDir = sessionDirectory( + for: id, + in: basePath + ) + + try fileManager.removeItem(at: sessionDir) + } + + internal func markSessionErrored(with id: String) throws { + let currentSessionDirectory = sessionDirectory( + for: id, + in: basePath + ) + + let erroredSessionDirectory = sessionDirectory( + for: "_\(id)", + in: basePath + ) + + try fileManager.moveItem( + at: currentSessionDirectory, + to: erroredSessionDirectory + ) + } + + // MARK: Private methods + + private func isHandleValid(handle: FileHandle) -> Bool { + let descriptor = handle.fileDescriptor + + // Cheapest way to check if a descriptor is still valid, without attempting to read/write to it. + return fcntl(descriptor, F_GETFL) != -1 || errno != EBADF + } + + internal func sessionDirectory( + for id: String, + in baseDirectory: URL + ) -> URL { + // TODO: Use FileWrapper to make it so we can actually open these as bundles. + // Note: Using a file wrapper may require changing the file enumerator to filter by isPackage + // instead of isDirectory. + return baseDirectory.appendDirectory("\(id).\(ParraSession.Constant.packageExtension)") + } + + internal static func sessionPaths( + in sessionDirectory: URL + ) -> ( + sessionPath: URL, + eventsPath: URL + ) { + // Just for conveinence while debugging to be able to open these files in an editor. +#if DEBUG + let sessionFileName = "session.json" + let eventsFileName = "events.csv" +#else + let sessionFileName = "session" + let eventsFileName = "events" +#endif + + return ( + sessionPath: sessionDirectory.appendFilename(sessionFileName), + eventsPath: sessionDirectory.appendFilename(eventsFileName) + ) + } +} diff --git a/Parra/PersistentStorage/Sessions/SessionReaderTests.swift b/Parra/PersistentStorage/Sessions/SessionReaderTests.swift new file mode 100644 index 000000000..13cdbb512 --- /dev/null +++ b/Parra/PersistentStorage/Sessions/SessionReaderTests.swift @@ -0,0 +1,451 @@ +// +// SessionReaderTests.swift +// ParraTests +// +// Created by Mick MacCallum on 10/9/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import XCTest +@testable import Parra + +final class SessionReaderTests: MockedParraTestCase { + private let fileManager = FileManager.default + private var sessionReader: SessionReader! + + override func setUp() async throws { + sessionReader = SessionReader( + basePath: baseStorageDirectory, + sessionJsonDecoder: .parraDecoder, + eventJsonDecoder: .spaceOptimizedDecoder, + fileManager: fileManager + ) + } + + override func tearDown() async throws { + try await super.tearDown() + + sessionReader = nil + } + + func testCanRetreiveSessionFileHandleCreatesHandleIfUnset() async throws { + let ctx = try createSessionContext() + + XCTAssertNil(sessionReader._sessionHandle) + XCTAssertNil(sessionReader._eventsHandle) + + let fileHandle = try sessionReader.retreiveFileHandleForSessionSync( + with: .session, + from: ctx + ) + + XCTAssertNotNil(sessionReader._sessionHandle) + XCTAssertIdentical(fileHandle, sessionReader._sessionHandle) + // Other handle should not have been created + XCTAssertNil(sessionReader._eventsHandle) + + // Handle offset should be 0 + XCTAssertEqual(try fileHandle.offset(), 0) + } + + func testCanRetreiveEventsFileHandleCreatesHandleIfUnset() async throws { + let ctx = try createSessionContext() + try fileManager.safeCreateFile(at: ctx.eventsPath) + + XCTAssertNil(sessionReader._eventsHandle) + XCTAssertNil(sessionReader._sessionHandle) + + let fileHandle = try sessionReader.retreiveFileHandleForSessionSync( + with: .events, + from: ctx + ) + + XCTAssertNotNil(sessionReader._eventsHandle) + XCTAssertIdentical(fileHandle, sessionReader._eventsHandle) + // Other handle should not have been created + XCTAssertNil(sessionReader._sessionHandle) + + // Handle offset should be 0, since there are no events + XCTAssertEqual(try fileHandle.offset(), 0) + } + + func testCanRetreiveEventsFileHandleCreatesHandleIfUnsetWithExistingEvents() async throws { + let ctx = try createSessionContext(includeEvents: true) + + XCTAssertNil(sessionReader._eventsHandle) + XCTAssertNil(sessionReader._sessionHandle) + + let fileHandle = try sessionReader.retreiveFileHandleForSessionSync( + with: .events, + from: ctx + ) + + XCTAssertNotNil(sessionReader._eventsHandle) + XCTAssertIdentical(fileHandle, sessionReader._eventsHandle) + // Other handle should not have been created + XCTAssertNil(sessionReader._sessionHandle) + + let initialOffset = try fileHandle.offset() + // If has events, offset after read should be greater than 0 + XCTAssertNotEqual(initialOffset, 0) + try fileHandle.seekToEnd() + // After seeking to end, the new offset didn't change, so initial offset + // should have been at the end of the file. + XCTAssertEqual(initialOffset, try fileHandle.offset()) + } + + func testCanRetreiveEventsFileHandleIfExists() async throws { + let ctx = try createSessionContext(includeEvents: true) + + // Initial read to create the handle + let initialHandle = try sessionReader.retreiveFileHandleForSessionSync( + with: .events, + from: ctx + ) + XCTAssertNotNil(sessionReader._eventsHandle) + XCTAssertNotNil(initialHandle) + + let secondHandle = try sessionReader.retreiveFileHandleForSessionSync( + with: .events, + from: ctx + ) + XCTAssertNotNil(sessionReader._eventsHandle) + XCTAssertNotNil(secondHandle) + + XCTAssertIdentical(initialHandle, secondHandle) + } + + func testGetAllSessionDirectoriesGetsSessions() async throws { + let contexts = [ + try createSessionContext(), + try createSessionContext(), + try createSessionContext(), + ] + + let allSessionDirectories = try sessionReader.getAllSessionDirectories() + + XCTAssertEqual(contexts.count, allSessionDirectories.count) + } + + func _testCanRetreiveSessionFileHandleClosedIfInvalid() async throws { + let ctx = try createSessionContext() + + // Initial read to create the handle + let initialHandle = try sessionReader.retreiveFileHandleForSessionSync( + with: .session, + from: ctx + ) + + // TODO: Would be nice to figure out how to test file handle becoming invalid. + + let secondHandle = try sessionReader.retreiveFileHandleForSessionSync( + with: .session, + from: ctx + ) + XCTAssertNotNil(sessionReader._sessionHandle) + XCTAssertNotNil(secondHandle) + + // Not identical. A new handle should have been created for the same file. + XCTAssertNotIdentical(initialHandle, secondHandle) + } + + func testGetAllSessionDirectoriesOmitsHiddenDirectories() async throws { + try createSessionContext() + try createSessionContext(hidden: true) + try createSessionContext() + + let allSessionDirectories = try sessionReader.getAllSessionDirectories() + + XCTAssertEqual(allSessionDirectories.count, 2) + } + + func testGetAllSessionDirectoriesHasPackageExtension() async throws { + try createSessionContext() + try createSessionContext() + try createSessionContext(withoutPackageExt: true) + try createSessionContext(withoutPackageExt: true) + try createSessionContext() + + let allSessionDirectories = try sessionReader.getAllSessionDirectories() + + XCTAssertEqual(allSessionDirectories.count, 3) + } + + func testCreatesSessionUploadGenerator() async throws { + let contexts = [ + try createSessionContext(includeEvents: true), + try createSessionContext(includeEvents: true), + try createSessionContext(includeEvents: true), + ] + + let generator = try sessionReader.generateSessionUploadsSync() + + var index = 0 + for await session in generator.makeAsyncIterator() { + switch session { + case .success(let sessionDirectory, _): + let matchingContext = contexts.first { context in + context.sessionPath.deletingLastPathComponent() == sessionDirectory + } + + XCTAssertNotNil(matchingContext) + case .error(_, let error): + throw error + } + + index += 1 + } + } + + func testClosingCurrentSessionClosesHandles() async throws { + let ctx = try createSessionContext(includeEvents: true) + + XCTAssertNil(sessionReader._sessionHandle) + XCTAssertNil(sessionReader._eventsHandle) + + let _ = try sessionReader.retreiveFileHandleForSessionSync( + with: .session, + from: ctx + ) + let _ = try sessionReader.retreiveFileHandleForSessionSync( + with: .events, + from: ctx + ) + + XCTAssertNotNil(sessionReader._sessionHandle) + XCTAssertNotNil(sessionReader._eventsHandle) + + try sessionReader.closeCurrentSessionSync() + + XCTAssertNil(sessionReader._sessionHandle) + XCTAssertNil(sessionReader._eventsHandle) + } + + func testClosingCurrentSessionRemovesCachedContext() async throws { + let ctx = try sessionReader.loadOrCreateSessionSync() + + let _ = try sessionReader.retreiveFileHandleForSessionSync( + with: .session, + from: ctx + ) + let _ = try sessionReader.retreiveFileHandleForSessionSync( + with: .events, + from: ctx + ) + + XCTAssertNotNil(sessionReader._currentSessionContext) + + try sessionReader.closeCurrentSessionSync() + + XCTAssertNil(sessionReader._currentSessionContext) + } + + func testLoadOrCreateCreatesIfNoSessionExists() async throws { + XCTAssertNil(sessionReader._currentSessionContext) + + let _ = try sessionReader.loadOrCreateSessionSync() + + XCTAssertNotNil(sessionReader._currentSessionContext) + } + + func testLoadOrCreateReturnsCachedSession() async throws { + let ctx1 = try sessionReader.loadOrCreateSessionSync() + + let ctx2 = try sessionReader.loadOrCreateSessionSync() + + XCTAssertEqual(ctx1, ctx2) + } + + func testLoadOrCreateLoadsFromDiskIfExistsAndValid() async throws { + // Create a fake context, which is stored. + let fakeSessionCtx = try createSessionContext() + + XCTAssertNil(sessionReader._currentSessionContext) + + let _ = try sessionReader.loadOrCreateSessionSync( + nextSessionId: fakeSessionCtx.session.sessionId + ) + + XCTAssertNotNil(sessionReader._currentSessionContext) + } + + func testLoadOrCreateCreatesIfDiskCacheIsInvalid() async throws { + // Create a fake context, which is stored. + let fakeSessionCtx = try createSessionContext() + + XCTAssertNil(sessionReader._currentSessionContext) + + let corruptJsonData = "i am not real json".data(using: .utf8)! + try corruptJsonData.write(to: fakeSessionCtx.sessionPath, options: .atomic) + + let newCtx = try sessionReader.loadOrCreateSessionSync( + nextSessionId: fakeSessionCtx.session.sessionId + ) + + XCTAssertNotNil(sessionReader._currentSessionContext) + XCTAssertEqual(sessionReader._currentSessionContext, newCtx) + } + + func testUpdateCachedSession() async throws { + let _ = try sessionReader.loadOrCreateSessionSync() + + let newSession = ParraSession( + sessionId: UUID().uuidString, + createdAt: Date(), + sdkVersion: Parra.libraryVersion() + ) + + XCTAssertNotNil(sessionReader._currentSessionContext) + XCTAssertNotEqual(newSession, sessionReader._currentSessionContext?.session) + + sessionReader.updateCachedCurrentSessionSync( + to: newSession + ) + + XCTAssertEqual(newSession, sessionReader._currentSessionContext?.session) + } + + func testDeletingSessionRemovesSessionDirectory() async throws { + let ctx = try sessionReader.loadOrCreateSessionSync() + + try sessionReader.deleteSessionSync( + with: ctx.session.sessionId + ) + + let exists = try fileManager.safeDirectoryExists( + at: ctx.eventsPath.deletingLastPathComponent() + ) + + XCTAssertFalse(exists) + } + + func testMarkingErroredSession() async throws { + let ctx = try sessionReader.loadOrCreateSessionSync() + let sessionDir = ctx.sessionPath.deletingLastPathComponent() + + XCTAssertFalse(sessionDir.lastPathComponent.hasPrefix("_")) + + try sessionReader.markSessionErrored( + with: ctx.session.sessionId + ) + + XCTAssertFalse(try fileManager.safeDirectoryExists(at: sessionDir)) + + let markedDir = "_\(sessionDir.lastPathComponent)" + let newSessionDir = sessionDir.deletingLastPathComponent().appendFilename(markedDir) + + XCTAssertTrue(try fileManager.safeDirectoryExists(at: newSessionDir)) + } + + @discardableResult + private func createSessionContext( + id: String = UUID().uuidString, + hidden: Bool = false, + withoutPackageExt: Bool = false, + includeEvents: Bool = false + ) throws -> SessionStorageContext { + var sessionDirectory = sessionReader.sessionDirectory( + for: id, + in: sessionReader.basePath + ) + + if hidden { + let last = ".\(sessionDirectory.lastPathComponent)" + + sessionDirectory = sessionDirectory + .deletingLastPathComponent() + .appendFilename(last) + } + + if withoutPackageExt { + sessionDirectory = sessionDirectory.deletingPathExtension() + } + + let (sessionPath, eventsPath) = SessionReader.sessionPaths( + in: sessionDirectory + ) + + try fileManager.safeCreateDirectory(at: sessionDirectory) + + let context = SessionStorageContext( + session: ParraSession( + sessionId: id, + createdAt: Date(), + sdkVersion: Parra.libraryVersion() + ), + sessionPath: sessionPath, + eventsPath: eventsPath + ) + + try fileManager.safeCreateFile(at: sessionPath) + try createSession( + at: sessionPath, + sessionId: id + ) + + if includeEvents { + try fileManager.safeCreateFile(at: eventsPath) + try createSessionEvents(at: eventsPath) + } + + return context + } + + private func createSession( + at path: URL, + sessionId: String + ) throws { + let session = ParraSession( + sessionId: sessionId, + createdAt: Date(), + sdkVersion: Parra.libraryVersion() + ) + + let handle = try FileHandle(forWritingTo: path) + defer { + try! handle.close() + } + + let data = try JSONEncoder.parraEncoder.encode(session) + + try handle.write(contentsOf: data) + try handle.synchronize() + + } + + private func createSessionEvents( + at path: URL + ) throws { + let events = [ + ParraSessionEvent( + createdAt: Date().addingTimeInterval(-10000.0), + name: "first event", + metadata: [:] + ), + ParraSessionEvent( + createdAt: Date().addingTimeInterval(-8000.0), + name: "second event", + metadata: [:] + ), + ParraSessionEvent( + createdAt: Date().addingTimeInterval(-4000.0), + name: "third event", + metadata: [:] + ) + ] + + // Keep after createSessionSync, since it will create intermediate directories. + let handle = try FileHandle(forWritingTo: path) + defer { + try! handle.close() + } + + for event in events { + var data = try JSONEncoder.spaceOptimizedEncoder.encode(event) + data.append("\n".data(using: .utf8)!) + + try handle.write(contentsOf: data) + try handle.synchronize() + } + } +} diff --git a/Parra/PersistentStorage/Sessions/SessionStorage.swift b/Parra/PersistentStorage/Sessions/SessionStorage.swift new file mode 100644 index 000000000..6127d4d9c --- /dev/null +++ b/Parra/PersistentStorage/Sessions/SessionStorage.swift @@ -0,0 +1,621 @@ +// +// SessionStorage.swift +// Parra +// +// Created by Mick MacCallum on 11/20/22. +// Copyright © 2022 Parra, Inc. All rights reserved. +// + +import Foundation + +// Should be able to: +// 1. Open a file handle when a session is started +// 2. Close the handle when the app resigns active and re-open it on foreground +// 3. Handle auto re-opening the handle if an event is added and the handle is closed +// 4. Write new events to disk as they come in. If a write is in progress +// queue them and flush them when the write finishes. +// 5. Should support optionally waiting for a write to complete. +// 6. Store session data in a seperate file from the events list. +// 7. Provide a way to get all the sessions without their events +// 8. Provide a way to delete all the sessions and their events + +// 7 and 8 so we can refactor bulk session submission to load then upload +// one at a time instead of loading all then uploading one at a time. + +fileprivate let logger = Logger(bypassEventCreation: true, category: "Session Storage") + +/// Handles underlying storage of sessions and events. The general approach that is taken is +/// to store a session object with id, user properties, etc and seperately store a list of events. +/// The file where events are written to will have a file handle held open to allow immediate writing +/// of single events. All of the internal work done within this class requires the use of serial queues. +/// async will only be used within this class to provide conveinence methods that are accessed externally. +internal class SessionStorage { + private struct Constant { + static let newlineCharData = "\n".data(using: .utf8)! + } + + fileprivate let storageQueue = DispatchQueue( + label: "com.parra.sessions.session-storage", + qos: .utility + ) + + private var isInitialized = false + + /// Whether or not, since the last time a sync occurred, session storage received + /// a new event with a context object that indicated that it was a high priority + /// event. + private var hasReceivedImportantEvent = false + + private let sessionReader: SessionReader + + private let sessionJsonEncoder: JSONEncoder + private let eventJsonEncoder: JSONEncoder + + internal init( + sessionReader: SessionReader, + sessionJsonEncoder: JSONEncoder, + eventJsonEncoder: JSONEncoder + ) { + self.sessionReader = sessionReader + self.sessionJsonEncoder = sessionJsonEncoder + self.eventJsonEncoder = eventJsonEncoder + } + + deinit { + logger.trace("deinit") + + do { + try sessionReader.closeCurrentSessionSync() + } catch let error { + logger.error("Error closing session reader/file handles", error) + } + } + + internal func initializeSessions() async { + logger.debug("initializing at path: \(sessionReader.basePath.lastComponents())") + + // Attempting to access the current session will force the session reader to load + // or create one, if one doesn't exist. Since this is only called during app launch, + // it will always create a new session, which we will turn around and persist immediately, + // as this is a requirement for the session to be considered initialized. If this method + // is mistakenly called multiple times, this will also ensure that the existing session + // will be used. + await withCheckedContinuation { continuation in + withCurrentSessionHandle { handle, session in + try self.writeSessionSync( + session: session, + with: handle + ) + } completion: { (result: Result) in + switch result { + case .success: + continuation.resume() + case .failure(let error): + logger.error("Error initializing session", error) + continuation.resume() + } + } + } + } + + internal func getCurrentSession() async throws -> ParraSession { + return try await withCheckedThrowingContinuation { continuation in + getCurrentSession { result in + continuation.resume(with: result) + } + } + } + + private func getCurrentSession( + completion: @escaping (Result) -> Void + ) { + withCurrentSessionHandle( + handler: { _, session in + return session + }, + completion: completion + ) + } + + // MARK: Updating Sessions + + internal func writeUserPropertyUpdate( + key: String, + value: Any? + ) { + withCurrentSessionHandle( + handler: { handle, session in +#if DEBUG + logger.debug("Updating user property", [ + "key": key, + "new value": String(describing: value), + "old value": String(describing: session.userProperties[key]) + ]) +#endif + + let updatedSession = session.withUpdatedProperty( + key: key, + value: value + ) + + try self.writeSessionSync( + session: updatedSession, + with: handle + ) + }, + completion: nil + ) + } + + private func storeEventContextUpdateSync( + context: ParraSessionEventContext + ) { + if hasReceivedImportantEvent { + // It doesn't matter if multiple important events are recievd. The first one + // is enough to set the flag. + return + } + + if context.isClientGenerated { + hasReceivedImportantEvent = true + + return + } + + if [.high, .critical].contains(context.syncPriority) { + hasReceivedImportantEvent = true + } + } + + internal func writeEvent( + event: ParraSessionEvent, + context: ParraSessionEventContext + ) { + withHandles { sessionHandle, eventsHandle, session in + self.storeEventContextUpdateSync(context: context) + + var data = try self.eventJsonEncoder.encode(event) + data.append(Constant.newlineCharData) + + try eventsHandle.write(contentsOf: data) + try eventsHandle.synchronize() + } completion: { result in + switch result { + case .success: + break + case .failure(let error): + logger.error("Error writing event to session", error) + } + } + } + + // MARK: Retrieving Sessions + + internal func getAllSessions() async throws -> ParraSessionUploadGenerator { + return try await withCheckedThrowingContinuation { continuation in + getAllSessions { result in + continuation.resume(with: result) + } + } + } + + private func getAllSessions( + completion: @escaping (Result) -> Void + ) { + withStorageQueue { sessionReader in + do { + let generator = try sessionReader.generateSessionUploadsSync() + + completion(.success(generator)) + } catch let error { + completion(.failure(error)) + } + } + } + + /// Whether or not there are new events on the session since the last time a sync was completed. + /// Will also return true if it isn't previously been called for the current session. + internal func hasNewEvents() async -> Bool { + do { + return try await withCheckedThrowingContinuation { continuation in + hasNewEvents { result in + continuation.resume(with: result) + } + } + } catch let error { + logger.error("Error checking if session has new events", error) + + return false + } + } + + private func hasNewEvents( + completion: @escaping (Result) -> Void + ) { + withCurrentSessionEventHandle( + handler: { [self] _, _ in + return hasReceivedImportantEvent + }, + completion: completion + ) + } + + internal func hasSessionUpdates(since date: Date?) async -> Bool { + do { + return try await withCheckedThrowingContinuation { continuation in + hasSessionUpdates(since: date) { result in + continuation.resume(with: result) + } + } + } catch let error { + logger.error("Error checking if session has updates", error) + + return false + } + } + + private func hasSessionUpdates( + since date: Date?, + completion: @escaping (Result) -> Void + ) { + withCurrentSessionHandle( + handler: { _, session in + return session.hasBeenUpdated(since: date) + }, + completion: completion + ) + } + + /// Whether or not there are sessions stored that have already finished. This implies that + /// there is a session in progress that is not taken into consideration. + internal func hasCompletedSessions() async -> Bool { + return await withCheckedContinuation { continuation in + hasCompletedSessions { + continuation.resume(returning: $0) + } + } + } + + private func hasCompletedSessions( + completion: @escaping (Bool) -> Void + ) { + withStorageQueue { sessionReader in + do { + let sessionDirectories = try self.sessionReader.getAllSessionDirectories() + let ext = ParraSession.Constant.packageExtension + let nonErroredSessions = sessionDirectories.filter { directory in + // Anything that is somehow in the sessions directory without the + // appropriate path extension should be counted towards completed sessions. + guard directory.pathExtension == ext else { + logger.trace("Encountered session directory with invalid path extension", [ + "name": directory.lastPathComponent + ]) + + return false + } + + let sessionId = directory.deletingPathExtension().lastPathComponent + + // Sessions that have errored are marked with an underscore prefix to + // their names. These sessions shouldn't count when checking if there + // are new sessions to sync, but will be picked up when a new session + // causes a sync to be necessary. + return !sessionId.hasPrefix(ParraSession.Constant.erroredSessionPrefix) + } + + logger.trace("Finished counting previous sessions", [ + "total": sessionDirectories.count, + "valid": nonErroredSessions.count + ]) + + // The current session counts for 1. If there are more that appear to + // be valid, use that as an indication that a sync should occur. + completion(nonErroredSessions.count > 1) + } catch let error { + logger.error("Error checking for completed sessions", error) + completion(false) + } + } + } + + internal func recordSyncBegan() async { + await withCheckedContinuation { continuation in + recordSyncBegan { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + logger.error("Error recording sync marker on session", error) + + continuation.resume() + } + } + } + } + + /// Store a marker to indicate that a sync just took place, so that events before that point + /// can be purged. + private func recordSyncBegan( + completion: @escaping (Result) -> Void + ) { + withHandles( + handler: { [self] sessionHandle, eventsHandle, session in + // Store the current offset of the file handle that writes events on the session object. + // This will be used last to know if more events have been written since this point. + let offset = try eventsHandle.offset() + + hasReceivedImportantEvent = false + + try writeSessionEventsOffsetUpdateSync( + to: session, + using: sessionHandle, + offset: offset + ) + }, + completion: completion + ) + } + + // MARK: Ending Sessions + + internal func endSession() async { + // TODO: Need to be consistent with deinit. Both should mark session as ended, write the update, then close the session reader +// sessionReader.closeCurrentSessionSync() + } + + // MARK: Deleting Sessions + + /// Deletes any data associated with the sessions with the provided ids. This includes + /// caches in memory and on disk. The current in progress session will not be deleted, + /// even if its id is provided. + internal func deleteSessions( + for sessionIds: Set, + erroredSessions erroredSessionIds: Set + ) async throws { + return try await withCheckedThrowingContinuation { continuation in + deleteSessions( + for: sessionIds, + erroredSessions: erroredSessionIds + ) { result in + continuation.resume(with: result) + } + } + } + + private func deleteSessions( + for sessionIds: Set, + erroredSessions erroredSessionIds: Set, + completion: @escaping (Result) -> Void + ) { + withHandles( + handler: { sessionHandle, eventsHandle, currentSession in + // Delete the directories for every session in sessionIds, except for the current session. + let sessionDirectories = try self.sessionReader.getAllSessionDirectories() + + for sessionDirectory in sessionDirectories { + let sessionId = sessionDirectory.deletingPathExtension().lastPathComponent + logger.trace("Session iterator produced session", [ + "sessionId": sessionId + ]) + + if sessionId == currentSession.sessionId { + continue + } + + if sessionIds.contains(sessionId) { + try self.sessionReader.deleteSessionSync(with: sessionId) + } + + if erroredSessionIds.contains(sessionId) { + try self.sessionReader.markSessionErrored(with: sessionId) + } + } + + logger.trace("Cleaning up previous events for current session") + + let currentEventsOffset = try eventsHandle.offset() + let cachedEventsOffset = currentSession.eventsHandleOffsetAtSync ?? 0 + + if currentEventsOffset > cachedEventsOffset { + logger.trace( + "Current events offset is greater than cache. New events have happened", + [ + "current": currentEventsOffset, + "cached": cachedEventsOffset + ] + ) + + // Delete all events for the current session that were written by the time the sync began. + + // File handle doesn't provide a way to truncate from the beginning of the file. + // We need to delete all the events that happened up until the point where a sync + // was started: + // 1. Seek to where the file handle was before the sync started. + // 2. Read all new data from that point into memory. + // 3. Reset the file back to a 0 offset. + // 4. Write the cached data back to the file. + + try eventsHandle.seek(toOffset: cachedEventsOffset) + if let data = try eventsHandle.readToEnd() { + logger.trace("Resetting events file with cached events") + try eventsHandle.truncate(atOffset: 0) + + try eventsHandle.write(contentsOf: data) + logger.trace("Finished writing cached events to reset events file") + } else { + logger.trace("New events couldn't be read") + // there are new events and the they can't be read + try eventsHandle.truncate(atOffset: 0) + } + + try eventsHandle.synchronize() + + let newOffset = try eventsHandle.offset() + + // Store the new position of the file handle as the last synced offset + // on the session. There are two situations that this handles: + // 1. There were no events written since the start of the sync. In this + // case, the new offset will be reset to 0. This is the same situation + // as the else block below. + // 2. There were events written between when the sync started and ended. + // It is expected that this is generally true, since networking events + // related to syncing will occur. When this happens, we advance the + // value of the last synchronized offset to be that of the handle, + // after storing these events that happened during sync. This has the + // drawback of meaning that these events won't be synchronized until + // some other events are created later, but has the benefit of helping + // break an infinite sync loop. + + try self.writeSessionEventsOffsetUpdateSync( + to: currentSession, + using: sessionHandle, + offset: newOffset + ) + } else { + logger.trace("No new events occurred. Resetting events file") + + // No new events were written since the sync started. Resetting the + // events file back to the beginning. + try eventsHandle.truncate(atOffset: 0) + try eventsHandle.synchronize() + + try self.writeSessionEventsOffsetUpdateSync( + to: currentSession, + using: sessionHandle, + offset: 0 + ) + } + }, + completion: completion + ) + } + + // MARK: Helpers + + private func writeSessionSync( + session: ParraSession, + with handle: FileHandle + ) throws { + // Always update the in memory cache of the current session before writing to disk. + sessionReader.updateCachedCurrentSessionSync( + to: session + ) + + let data = try sessionJsonEncoder.encode(session) + + let offset = try handle.offset() + if offset > data.count { + // If the old file was longer, free the unneeded bytes + try handle.truncate(atOffset: UInt64(data.count)) + } + + try handle.seek(toOffset: 0) + try handle.write(contentsOf: data) + try handle.synchronize() + } + + private func writeSessionEventsOffsetUpdateSync( + to session: ParraSession, + using handle: FileHandle, + offset: UInt64 + ) throws { + logger.trace("Updating events file handle offset to: \(offset)") + + let updatedSession = session.withUpdatedEventsHandleOffset( + offset: offset + ) + + try self.writeSessionSync( + session: updatedSession, + with: handle + ) + } +} + +// MARK: withHandle helpers + +extension SessionStorage { + private func withCurrentSessionHandle( + handler: @escaping (FileHandle, ParraSession) throws -> T, + completion: ((Result) -> Void)? = nil + ) { + withHandle( + for: .session, + completion: completion, + handler: handler + ) + } + + private func withCurrentSessionEventHandle( + handler: @escaping (FileHandle, ParraSession) throws -> T, + completion: ((Result) -> Void)? = nil + ) { + withHandle( + for: .events, + completion: completion, + handler: handler + ) + } + + private func withHandles( + handler: @escaping ( + _ sessionHandle: FileHandle, + _ eventsHandle: FileHandle, + _ session: ParraSession + ) throws -> T, + completion: ((Result) -> Void)? = nil + ) { + withStorageQueue { sessionReader in + do { + let context = try sessionReader.loadOrCreateSessionSync() + + let sessionHandle = try sessionReader.retreiveFileHandleForSessionSync( + with: .session, + from: context + ) + + let eventsHandle = try sessionReader.retreiveFileHandleForSessionSync( + with: .events, + from: context + ) + + completion?( + .success(try handler(sessionHandle, eventsHandle, context.session)) + ) + } catch let error { + completion?(.failure(error)) + } + } + } + + private func withHandle( + for type: FileHandleType, + completion: ((Result) -> Void)? = nil, + handler: @escaping (FileHandle, ParraSession) throws -> T + ) { + withStorageQueue { sessionReader in + // TODO: Needs logic to see if the error is related to the file handle being closed, and auto attempt to reopen it. + do { + let context = try sessionReader.loadOrCreateSessionSync() + let handle = try sessionReader.retreiveFileHandleForSessionSync( + with: type, + from: context + ) + + completion?( + .success(try handler(handle, context.session)) + ) + } catch let error { + completion?(.failure(error)) + } + } + } + + private func withStorageQueue( + block: @escaping (_ sessionReader: SessionReader) -> Void + ) { + storageQueue.async { [self] in + block(sessionReader) + } + } +} diff --git a/Parra/PersistentStorage/Sessions/SessionStorageContext.swift b/Parra/PersistentStorage/Sessions/SessionStorageContext.swift new file mode 100644 index 000000000..d551498de --- /dev/null +++ b/Parra/PersistentStorage/Sessions/SessionStorageContext.swift @@ -0,0 +1,20 @@ +// +// SessionStorageContext.swift +// Parra +// +// Created by Mick MacCallum on 8/14/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal struct SessionStorageContext: Equatable { + internal private(set) var session: ParraSession + let sessionPath: URL + let eventsPath: URL + + mutating func updateSession(to newSession: ParraSession) { + session = newSession + } +} + diff --git a/Parra/Sessions/Analytics/Parra+InternalAnalytics.swift b/Parra/Sessions/Analytics/Parra+InternalAnalytics.swift new file mode 100644 index 000000000..6e583334d --- /dev/null +++ b/Parra/Sessions/Analytics/Parra+InternalAnalytics.swift @@ -0,0 +1,122 @@ +// +// Parra+InternalAnalytics.swift +// Parra +// +// Created by Mick MacCallum on 6/25/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal extension Parra { + + @inlinable + static func logEvent( + _ event: ParraInternalEvent, + _ extra: [String : Any], + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + getSharedInstance().sessionManager.writeEvent( + wrappedEvent: .internalEvent( + event: event, + extra: extra + ), + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @inlinable + static func logEvent( + _ event: ParraInternalEvent, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + getSharedInstance().sessionManager.writeEvent( + wrappedEvent: .internalEvent( + event: event, + extra: nil + ), + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @inlinable + func logEvent( + _ event: ParraInternalEvent, + _ extra: [String : Any], + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + sessionManager.writeEvent( + wrappedEvent: .internalEvent( + event: event, + extra: extra + ), + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @inlinable + func logEvent( + _ event: ParraInternalEvent, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + sessionManager.writeEvent( + wrappedEvent: .internalEvent( + event: event, + extra: nil + ), + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } +} diff --git a/Parra/Sessions/Analytics/Parra+PublicAnalytics.swift b/Parra/Sessions/Analytics/Parra+PublicAnalytics.swift new file mode 100644 index 000000000..f2cbf271b --- /dev/null +++ b/Parra/Sessions/Analytics/Parra+PublicAnalytics.swift @@ -0,0 +1,168 @@ +// +// Parra+PublicAnalytics.swift +// Parra +// +// Created by Mick MacCallum on 12/29/22. +// Copyright © 2022 Parra, Inc. All rights reserved. +// + +import Foundation + +// event vs data event. event just has a name. data event has name and extra + +public extension Parra { + // MARK: - Analytics Events + + /// Logs a new event to the user's current session in Parra Analytics. Events can be used to activate + /// campaigns configured in the Parra dashboard. + @inlinable + static func logEvent( + _ event: ParraStandardEvent, + _ extra: [String : Any]? = nil, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + getSharedInstance().sessionManager.writeEvent( + wrappedEvent: .dataEvent(event: event, extra: extra), + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + /// Logs a new event to the user's current session in Parra Analytics. Events can be used to activate + /// campaigns configured in the Parra dashboard. + @inlinable + static func logEvent( + _ event: ParraDataEvent, + _ extra: [String : Any]? = nil, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + getSharedInstance().sessionManager.writeEvent( + wrappedEvent: .dataEvent(event: event, extra: extra), + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + /// Logs a new event to the user's current session in Parra Analytics. Events can be used to activate + /// campaigns configured in the Parra dashboard. + @inlinable + static func logEvent( + _ event: ParraEvent, + _ extra: [String : Any]? = nil, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + getSharedInstance().sessionManager.writeEvent( + wrappedEvent: .event(event: event, extra: extra), + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + /// Logs a new event to the user's current session in Parra Analytics. Events can be used to activate + /// campaigns configured in the Parra dashboard. + @inlinable + static func logEvent( + named eventName: String, + _ extra: [String : Any]? = nil, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + let wrappedEvent: ParraWrappedEvent + if let extra, !extra.isEmpty { + wrappedEvent = .dataEvent( + event: ParraBasicDataEvent( + name: eventName, + extra: extra + ) + ) + } else { + wrappedEvent = .event( + event: ParraBasicEvent( + name: eventName + ) + ) + } + + getSharedInstance().sessionManager.writeEvent( + wrappedEvent: wrappedEvent, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + // MARK: - User Properties + + /// Attaches a property to the current user, as defined by the Parra authentication handler. User properties + /// can be used to activate campaigns configured in the Parra dashboard. + static func setUserProperty( + _ value: Any, + forKey key: Key + ) where Key: CustomStringConvertible { + setUserProperty(value, forKey: key.description) + } + + /// Attaches a property to the current user, as defined by the Parra authentication handler. User properties + /// can be used to activate campaigns configured in the Parra dashboard. + static func setUserProperty( + _ value: Any, + forKey key: Key + ) where Key: RawRepresentable, Key.RawValue == String { + setUserProperty(value, forKey: key.rawValue) + } + + /// Attaches a property to the current user, as defined by the Parra authentication handler. User properties + /// can be used to activate campaigns configured in the Parra dashboard. + static func setUserProperty( + _ value: Any, + forKey key: String + ) { + getSharedInstance().sessionManager.setUserProperty(value, forKey: key) + } +} diff --git a/Parra/Sessions/Extensions/ParraDiskUsage+ParraSessionParamDictionaryConvertible.swift b/Parra/Sessions/Extensions/ParraDiskUsage+ParraSessionParamDictionaryConvertible.swift new file mode 100644 index 000000000..93dd7607f --- /dev/null +++ b/Parra/Sessions/Extensions/ParraDiskUsage+ParraSessionParamDictionaryConvertible.swift @@ -0,0 +1,20 @@ +// +// ParraDiskUsage+ParraDictionaryConvertible.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +extension ParraDiskUsage: ParraSanitizedDictionaryConvertible { + var sanitized: ParraSanitizedDictionary { + return [ + "total_capacity": totalCapacity, + "available_capacity": availableCapacity, + "available_essential_capacity": availableEssentialCapacity, + "available_opportunistic_capacity": availableOpportunisticCapacity, + ] + } +} diff --git a/Parra/Sessions/Extensions/Thread.swift b/Parra/Sessions/Extensions/Thread.swift new file mode 100644 index 000000000..7752f2985 --- /dev/null +++ b/Parra/Sessions/Extensions/Thread.swift @@ -0,0 +1,110 @@ +// +// Thread.swift +// Parra +// +// Created by Mick MacCallum on 11/26/22. +// Copyright © 2022 Parra, Inc. All rights reserved. +// + +import Foundation + +extension Thread { + + /// Gets the name and number of the thread. It is possible that the number will change, + /// but could potentially be useful tracing information used in combination with the pthread id. + /// Name might only exist for the main thread but more testing is needed. + /// + /// It would also be nice to not have to parse this, but the only alternative option is currently + /// to access private fields on NSThread via valueForKey. + var threadNameAndNumber: (String?, UInt8)? { + // Example of what we're parsing. + // "{number = 11, name = (null)}" + // This is really annoying because name and number could be in either order. + + if isMainThread { + return ("main", 1) + } + + let components = description.split(separator: "{") + guard let end = components.last?.dropLast(), components.count == 2 else { + return nil + } + + let subComponents = end.components(separatedBy: ", ") + guard subComponents.count == 2 else { + // There are more properties that we weren't aware of + // This could eventually be handled by enumerating the subComponents array + // and checking prefixes before the = for known keys. + return nil + } + + guard let dividerRange = end.range(of: ", ") else { + return nil + } + + let numPrefix = "number = " + guard let numPrefixEndIdx = end.range(of: numPrefix)?.upperBound else { + return nil + } + + let threadNumber: UInt8? + let isNameFirst: Bool + if numPrefixEndIdx < dividerRange.lowerBound { + // number was first + let numberString = end[numPrefixEndIdx.. ParraDiskUsage? { + do { + let resourceValues = try ParraDataManager.Base.documentDirectory.resourceValues( + forKeys: [ + .volumeTotalCapacityKey, .volumeAvailableCapacityKey, + .volumeAvailableCapacityForImportantUsageKey, .volumeAvailableCapacityForOpportunisticUsageKey + ] + ) + + return ParraDiskUsage(resourceValues: resourceValues) + } catch let error { + Logger.error("Error fetching current disk usage", error) + + return nil + } + } +} diff --git a/Parra/Sessions/Logger/CallStackParser/CallStackFrame.swift b/Parra/Sessions/Logger/CallStackParser/CallStackFrame.swift new file mode 100644 index 000000000..b9791fab7 --- /dev/null +++ b/Parra/Sessions/Logger/CallStackParser/CallStackFrame.swift @@ -0,0 +1,37 @@ +// +// CallStackFrame.swift +// Parra +// +// Created by Mick MacCallum on 7/22/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +/// Field definitions from [Apple documentation(https://developer.apple.com/documentation/xcode/examining-the-fields-in-a-crash-report#Backtraces) +@usableFromInline +internal struct CallStackFrame: Codable { + /// The stack frame number. Stack frames are in calling order, where frame 0 is the function that + /// was executing at the time execution halted. Frame 1 is the function that called the + /// function in frame 0, and so on. + let frameNumber: UInt8 + + /// The name of the binary containing the function that is executing. + let binaryName: String + + /// The address of the machine instruction that is executing. For frame 0 in each backtrace, this is the + /// address of the machine instruction executing on a thread when the process terminated. For other stack + /// frames, this is the address of first machine instruction that executes after control returns to that + /// stack frame. + let address: UInt64 + + /// The name of the function that is executing. + let symbol: String + + /// The byte offset from the function’s entry point to the current instruction in the function. + let byteOffset: UInt16 + + /// The file name and line number containing the code, if you have a dSYM file for the binary. + let fileName: String? + let lineNumber: UInt8? +} diff --git a/Parra/Sessions/Logger/CallStackParser/CallStackParser.swift b/Parra/Sessions/Logger/CallStackParser/CallStackParser.swift new file mode 100644 index 000000000..d41c93f01 --- /dev/null +++ b/Parra/Sessions/Logger/CallStackParser/CallStackParser.swift @@ -0,0 +1,277 @@ +// +// CallStackParser.swift +// Parra +// +// Created by Mick MacCallum on 7/22/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation +import Darwin + +fileprivate let logger = Logger(bypassEventCreation: true, category: "Symbolication") + +fileprivate typealias Swift_Demangle = @convention(c) ( + _ mangledName: UnsafePointer?, + _ mangledNameLength: Int, + _ outputBuffer: UnsafeMutablePointer?, + _ outputBufferSize: UnsafeMutablePointer?, + _ flags: UInt32 +) -> UnsafeMutablePointer? + +fileprivate typealias Swift_Backtrace = @convention(c) ( + _ address: UnsafeMutablePointer, + _ stackSize: Int32 +) -> Int32 + +// https://developer.apple.com/documentation/xcode/adding-identifiable-symbol-names-to-a-crash-report/ + +internal struct CallStackParser { + fileprivate struct Constant { + /// In order by expected likelihood that they'll be encountered + /// https://github.com/apple/swift/blob/b5ddffdb3d095e4a57abaac3f8c1e327d64ebea1/lib/Demangling/Demangler.cpp#L181-L184 +#if swift(>=5.0) + static let swiftSymbolPrefixes = [ + // Swift 5+ + "$s", "_$s", + // Swift 5+ for filenames + "@__swiftmacro_" + ] +#elseif swift(>=4.1) + static let swiftSymbolPrefixes = [ + // Swift 4.x + "$S", "_$S" + ] +#else + static let swiftSymbolPrefixes = [ + // Swift 4 + "_T0" + ] +#endif + } + + internal static func parse( + frames: [String] + ) -> [CallStackFrame] { + return frames.compactMap { frame in + let components = frame.split { char in + return char.isWhitespace || char.isNewline + } + + assert(components.count >= 6 || components.count <= 8, "Unknown stack frame format: \(frame)") + + guard let frameNumber = UInt8(components[0]), + let address = UInt64(components[2].dropFirst(2), radix: 16) else { + return nil + } + + guard let plusIndex = components.firstIndex(of: "+"), plusIndex >= 4 else { + return nil + } + + guard let byteOffset = UInt16(components[plusIndex + 1]) else { + return nil + } + + let rawSymbol = components[3...(plusIndex - 1)].joined(separator: " ") + + var fileInfo: (String, UInt8)? + if let last = components.last, last.starts(with: "("), components.count == plusIndex + 3 { + let trimmed = last.trimmingCharacters(in: .punctuationCharacters) + + let fileComponents = trimmed.split(separator: ":") + + if let name = fileComponents.first, + let lineString = fileComponents.last, + let line = UInt8(lineString), + fileComponents.count == 2 { + + fileInfo = (String(name), line) + } + } + + let symbol = demangleSymbolIfNeeded(symbol: rawSymbol) + let binaryName = String(components[1]) + + return CallStackFrame( + frameNumber: frameNumber, + binaryName: binaryName, + address: address, + symbol: symbol, + byteOffset: byteOffset, + fileName: fileInfo?.0, + lineNumber: fileInfo?.1 + ) + } + } + + private static func demangleSymbolIfNeeded( + symbol: String + ) -> String { + + if symbol.hasAnyPrefix(Constant.swiftSymbolPrefixes) { + // Swift symbols need to be demangled. Failing to demangle should at least + // return the raw symbol. + return demangle(symbol: symbol) ?? symbol + } + + // Objective-C symbols do not need to be demangled. It is also possible that a symbol + // is unsymbolicated, in which case it will be a memory address. C++ can also require + // symbolication, but that's for another day. + + return symbol + } + + /// 🤞 https://github.com/apple/swift-evolution/blob/main/proposals/0262-demangle.md + private static func demangle(symbol mangled: String) -> String? { + let RTLD_DEFAULT = dlopen(nil, RTLD_NOW) + + guard let sym = dlsym(RTLD_DEFAULT, "swift_demangle") else { + return nil + } + + let f = unsafeBitCast(sym, to: Swift_Demangle.self) + guard let cString = f(mangled, mangled.count, nil, nil, 0) else { + return nil + } + + defer { + cString.deallocate() + } + + return String(cString: cString) + } + + internal static func printBacktrace() { + do { + let symbols = try backtrace() + + switch symbols { + case .none: + logger.info("Symbol type is none") + case .raw(let frames): + logger.info(frames.joined(separator: "\n")) + case .demangled(let frames): + let stringFrames = frames.map({ frame in + return "\(frame.frameNumber)\t\(frame.binaryName)\t\(frame.address)\t\(frame.symbol) + \(frame.byteOffset)" + }).joined(separator: "\n") + + logger.info(stringFrames) + } + + } catch let error { + logger.error(error) + } + } + + private static func backtrace( + stackSize: Int = 256 + ) throws -> ParraLoggerStackSymbols { + let RTLD_DEFAULT = dlopen(nil, RTLD_NOW) + + guard let sym = dlsym(RTLD_DEFAULT, "backtrace") else { + throw ParraError.generic("Error linking backtrace function.", nil) + } + + let backtrace = unsafeBitCast(sym, to: Swift_Backtrace.self) + + let addresses = UnsafeMutablePointer.allocate( + capacity: stackSize + ) + + defer { + addresses.deallocate() + } + + let frameCount = backtrace(addresses, Int32(stackSize)) + let buffer = UnsafeBufferPointer( + start: addresses, + count: Int(frameCount) + ) + + let stackFrames: [CallStackFrame] = buffer.enumerated().compactMap { (index, address) in + guard let address else { + return nil + } + + let dlInfoPrt = UnsafeMutablePointer.allocate( + capacity: 1 + ) + + defer { + dlInfoPrt.deallocate() + } + + guard dladdr(address, dlInfoPrt) != 0 else { + return nil + } + + let info = dlInfoPrt.pointee + + // Name of nearest symbol + let rawSymbol = String(cString: info.dli_sname) + // Pathname of shared object + let fileName = String(cString: info.dli_fname) + // Address of nearest symbol + let symbolAddressValue = unsafeBitCast(info.dli_saddr, to: UInt64.self) + let addressValue = UInt16(UInt(bitPattern: address)) + + let symbol = demangleSymbolIfNeeded(symbol: rawSymbol) + + // TODO: Review these values + return CallStackFrame( + frameNumber: UInt8(index), + binaryName: "", + address: symbolAddressValue, + symbol: symbol, + byteOffset: addressValue, + fileName: fileName, + lineNumber: nil + ) + } + + return .demangled(stackFrames) + } + + // TODO: When doing crash handlers for real, note that it is undefined behavior to malloc after SIGKILL + // in this case it is important to use backtrace_symbols_fd to write the symbols to a file to be + // read on the next app launch. + // https://github.com/getsentry/sentry-cocoa/issues/1919#issuecomment-1360987627 + + + // @_silgen_name("backtrace") + // fileprivate func backtrace(_: UnsafeMutablePointer!, _: UInt32) -> UInt32 + // internal static func addSigKillHandler() { + // setupHandler(for: SIGKILL) { _ in + // // this is all undefined behaviour, not allowed to malloc or call backtrace here... + // + // let maxFrames = 50 + // let stackSymbols: UnsafeMutableBufferPointer = .allocate(capacity: maxFrames) + // stackSymbols.initialize(repeating: nil) + // let howMany = backtrace(stackSymbols.baseAddress!, UInt32(CInt(maxFrames))) + // let ptr = backtrace_symbols(stackSymbols.baseAddress!, howMany) + // let realAddresses = Array(UnsafeBufferPointer(start: ptr, count: Int(howMany))).compactMap { $0 } + // realAddresses.forEach { + // print(String(cString: $0)) + // } + // } + // } + // + // internal static func setupHandler( + // for signal: Int32, + // handler: @escaping @convention(c) (CInt) -> Void + // ) { + // typealias SignalAction = sigaction + // + // let flags = CInt(SA_NODEFER) | CInt(bitPattern: CUnsignedInt(SA_RESETHAND)) + // var signalAction = SignalAction( + // __sigaction_u: unsafeBitCast(handler, to: __sigaction_u.self), + // sa_mask: sigset_t(), + // sa_flags: flags + // ) + // + // withUnsafePointer(to: &signalAction) { ptr in + // sigaction(signal, ptr, nil) + // } + // } +} diff --git a/Parra/Sessions/Logger/Extensions/ProcessInfoPowerState+ParraLogStringConvertible.swift b/Parra/Sessions/Logger/Extensions/ProcessInfoPowerState+ParraLogStringConvertible.swift new file mode 100644 index 000000000..c2d70849d --- /dev/null +++ b/Parra/Sessions/Logger/Extensions/ProcessInfoPowerState+ParraLogStringConvertible.swift @@ -0,0 +1,35 @@ +// +// ProcessInfoPowerState+ParraLogStringConvertible.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +extension ProcessInfo { + var powerState: PowerState { + return isLowPowerModeEnabled ? .lowPowerMode : .normal + } + + enum PowerState { + case normal + case lowPowerMode + } +} + +extension ProcessInfo.PowerState: ParraLogStringConvertible, CustomStringConvertible { + var loggerDescription: String { + switch self { + case .normal: + return "normal" + case .lowPowerMode: + return "low_power_mode" + } + } + + var description: String { + return loggerDescription + } +} diff --git a/Parra/Sessions/Logger/Extensions/ProcessInfoThermalState+ParraLogStringConvertible.swift b/Parra/Sessions/Logger/Extensions/ProcessInfoThermalState+ParraLogStringConvertible.swift new file mode 100644 index 000000000..93f10023a --- /dev/null +++ b/Parra/Sessions/Logger/Extensions/ProcessInfoThermalState+ParraLogStringConvertible.swift @@ -0,0 +1,30 @@ +// +// ProcessInfoThermalState+ParraLogStringConvertible.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +extension ProcessInfo.ThermalState: ParraLogStringConvertible, CustomStringConvertible { + var loggerDescription: String { + switch self { + case .nominal: + return "nominal" + case .fair: + return "fair" + case .serious: + return "serious" + case .critical: + return "critical" + default: + return "unknown" + } + } + + public var description: String { + return loggerDescription + } +} diff --git a/Parra/Sessions/Logger/Extensions/Public/UIViewController+defaultLogger.swift b/Parra/Sessions/Logger/Extensions/Public/UIViewController+defaultLogger.swift new file mode 100644 index 000000000..d61a0dd69 --- /dev/null +++ b/Parra/Sessions/Logger/Extensions/Public/UIViewController+defaultLogger.swift @@ -0,0 +1,77 @@ +// +// UIViewController+defaultLogger.swift +// Parra +// +// Created by Mick MacCallum on 9/1/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import UIKit +import ObjectiveC + +internal final class ObjectAssociation { + private let policy: objc_AssociationPolicy + + /// - Parameter policy: An association policy that will be used when linking objects. + public init( + policy: objc_AssociationPolicy = .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) { + + self.policy = policy + } + + /// Accesses associated object. + /// - Parameter index: An object whose associated object is to be accessed. + public subscript(index: AnyObject) -> T? { + get { + return objc_getAssociatedObject( + index, + Unmanaged.passUnretained(self).toOpaque() + ) as! T? + } + + set { + objc_setAssociatedObject( + index, + Unmanaged.passUnretained(self).toOpaque(), + newValue, + policy + ) + } + } +} + +public extension UIViewController { + private static let association = ObjectAssociation() + + var logger: Logger { + get { + if let existing = UIViewController.association[self] { + return existing + } + + // TODO: Audit/make into helper for other classes. + let category = String(describing: type(of: self)) + + let fileId = type(of: self).description().split(separator: ".").joined(separator: "/") + + var extra: [String : Any] = [ + "hasNavigationController": navigationController != nil + ] + if let title { + extra["title"] = title + } + + let new = Logger( + category: "ViewController (\(category))", + extra: extra, + fileId: fileId, + function: "" + ) + + UIViewController.association[self] = new + + return new + } + } +} diff --git a/Parra/Sessions/Logger/Extensions/QualityOfService+ParraLogDescription.swift b/Parra/Sessions/Logger/Extensions/QualityOfService+ParraLogDescription.swift new file mode 100644 index 000000000..d52a2c0b8 --- /dev/null +++ b/Parra/Sessions/Logger/Extensions/QualityOfService+ParraLogDescription.swift @@ -0,0 +1,32 @@ +// +// QualityOfService+ParraLogDescription.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +extension QualityOfService: ParraLogStringConvertible, CustomStringConvertible { + var loggerDescription: String { + switch self { + case .background: + return "background" + case .utility: + return "utility" + case .default: + return "default" + case .userInitiated: + return "user_initiated" + case .userInteractive: + return "user_interactive" + default: + return "unknown" + } + } + + public var description: String { + return loggerDescription + } +} diff --git a/Parra/Extensions/UIApplication.swift b/Parra/Sessions/Logger/Extensions/UIApplicationState+ParraLogStringConvertible.swift similarity index 65% rename from Parra/Extensions/UIApplication.swift rename to Parra/Sessions/Logger/Extensions/UIApplicationState+ParraLogStringConvertible.swift index 249ce71ae..b5e94e496 100644 --- a/Parra/Extensions/UIApplication.swift +++ b/Parra/Sessions/Logger/Extensions/UIApplicationState+ParraLogStringConvertible.swift @@ -1,5 +1,5 @@ // -// UIApplication.swift +// UIApplicationState+ParraLogStringConvertible.swift // Parra // // Created by Mick MacCallum on 1/19/23. @@ -8,8 +8,8 @@ import UIKit -extension UIApplication.State: CustomStringConvertible { - public var description: String { +extension UIApplication.State: ParraLogStringConvertible, CustomStringConvertible { + public var loggerDescription: String { switch self { case .active: return "active" @@ -21,4 +21,8 @@ extension UIApplication.State: CustomStringConvertible { return "unknown" } } + + public var description: String { + return loggerDescription + } } diff --git a/Parra/Sessions/Logger/Extensions/UIDeviceBatteryState+ParraLogStringConvertible.swift b/Parra/Sessions/Logger/Extensions/UIDeviceBatteryState+ParraLogStringConvertible.swift new file mode 100644 index 000000000..1cf9bdddf --- /dev/null +++ b/Parra/Sessions/Logger/Extensions/UIDeviceBatteryState+ParraLogStringConvertible.swift @@ -0,0 +1,30 @@ +// +// UIDeviceBatteryState+ParraLogStringConvertible.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import UIKit + +extension UIDevice.BatteryState: ParraLogStringConvertible, CustomStringConvertible { + public var loggerDescription: String { + switch self { + case .unknown: + return "unknown" + case .unplugged: + return "unplugged" + case .charging: + return "charging" + case .full: + return "full" + @unknown default: + return "unknown" + } + } + + public var description: String { + return loggerDescription + } +} diff --git a/Parra/Extensions/UIDeviceOrientation.swift b/Parra/Sessions/Logger/Extensions/UIDeviceOrientation+ParraLogStringConvertible.swift similarity index 60% rename from Parra/Extensions/UIDeviceOrientation.swift rename to Parra/Sessions/Logger/Extensions/UIDeviceOrientation+ParraLogStringConvertible.swift index 80e574705..615b70668 100644 --- a/Parra/Extensions/UIDeviceOrientation.swift +++ b/Parra/Sessions/Logger/Extensions/UIDeviceOrientation+ParraLogStringConvertible.swift @@ -8,25 +8,29 @@ import UIKit -extension UIDeviceOrientation: CustomStringConvertible { - public var description: String { +extension UIDeviceOrientation: ParraLogStringConvertible, CustomStringConvertible { + public var loggerDescription: String { switch self { case .portrait: return "portrait" case .portraitUpsideDown: - return "portraitUpsideDown" + return "portrait_upside_down" case .landscapeLeft: - return "landscapeLeft" + return "landscape_left" case .landscapeRight: - return "landscapeRight" + return "landscape_right" case .faceUp: - return "faceUp" + return "face_up" case .faceDown: - return "faceDown" + return "face_down" case .unknown: fallthrough default: return "unknown" } } + + public var description: String { + return loggerDescription + } } diff --git a/Parra/Sessions/Logger/Logger+Levels.swift b/Parra/Sessions/Logger/Logger+Levels.swift new file mode 100644 index 000000000..77dad1133 --- /dev/null +++ b/Parra/Sessions/Logger/Logger+Levels.swift @@ -0,0 +1,724 @@ +// +// Logger+Levels.swift +// Parra +// +// Created by Mick MacCallum on 7/5/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +// +// +// Heads up! This file contains a whole bunch of duplicated code that can't really be avoided. +// Here we provide a series of instance methods for the Logger that allow for logging at +// different levels and providing different parameters. We had to provide multiple overloads +// to keep these easy to use, and each requires capturing thread and call site information, +// which can not be abstracted out without captuing incorrect data. +// +// + +import Foundation +import Darwin + +// TODO: Make another set of overloads that allow OSLogMessage/interpolation to be passed instead of string +// Should there be one more layer of wrapper around these to try to obscure all the tracking via default values? +// Related to ^, could we make use of @inlinable to do this, or to avoid having to parse Parra +// frames out of stack traces? Also maybe help with Xcode 15 console showing button to jump to wrong file. + +public extension Logger { + @discardableResult + @inlinable + func trace( + _ message: @autoclosure @escaping () -> String, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + return logToBackend( + level: .trace, + message: .string(message), + extra: nil, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + func trace( + _ message: @autoclosure @escaping () -> String, + _ extra: [String : Any], + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + return logToBackend( + level: .trace, + message: .string(message), + extra: extra, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + func debug( + _ message: @autoclosure @escaping () -> String, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + return logToBackend( + level: .debug, + message: .string(message), + extra: nil, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + func debug( + _ message: @autoclosure @escaping () -> String, + _ extra: [String : Any], + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + return logToBackend( + level: .debug, + message: .string(message), + extra: extra, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + func info( + _ message: @autoclosure @escaping () -> String, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + return logToBackend( + level: .info, + message: .string(message), + extra: nil, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + func info( + _ message: @autoclosure @escaping () -> String, + _ extra: [String : Any], + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + return logToBackend( + level: .info, + message: .string(message), + extra: extra, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + func warn( + _ message: @autoclosure @escaping () -> String, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + return logToBackend( + level: .warn, + message: .string(message), + extra: nil, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + func warn( + _ message: @autoclosure @escaping () -> String, + _ extra: [String : Any], + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + return logToBackend( + level: .warn, + message: .string(message), + extra: extra, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + func error( + _ message: @autoclosure @escaping () -> String, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .error, + message: .string(message), + extra: nil, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + func error( + _ message: @autoclosure @escaping () -> String, + _ extra: [String : Any], + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .error, + message: .string(message), + extra: extra, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + func error( + _ error: @autoclosure @escaping () -> ParraError, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .error, + message: .error(error), + extra: nil, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + func error( + _ error: @autoclosure @escaping () -> ParraError, + _ extra: [String : Any], + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .error, + message: .error(error), + extra: extra, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + func error( + _ error: @autoclosure @escaping () -> Error, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .error, + message: .error(error), + extra: nil, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + func error( + _ error: @autoclosure @escaping () -> Error, + _ extra: [String : Any], + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .error, + message: .error(error), + extra: extra, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + func error( + _ message: @autoclosure @escaping () -> String, + _ error: Error? = nil, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .error, + message: .string(message), + extraError: error, + extra: nil, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + func error( + _ message: @autoclosure @escaping () -> String, + _ error: Error? = nil, + _ extra: [String : Any], + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .error, + message: .string(message), + extraError: error, + extra: extra, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + func fatal( + _ message: @autoclosure @escaping () -> String, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .fatal, + message: .string(message), + extra: nil, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + func fatal( + _ message: @autoclosure @escaping () -> String, + _ extra: [String : Any], + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .fatal, + message: .string(message), + extra: extra, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + func fatal( + _ error: @autoclosure @escaping () -> Error, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .fatal, + message: .error(error), + extra: nil, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + func fatal( + _ error: @autoclosure @escaping () -> Error, + _ extra: [String : Any], + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .fatal, + message: .error(error), + extra: extra, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + func fatal( + _ message: @autoclosure @escaping () -> String, + _ error: Error? = nil, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .fatal, + message: .string(message), + extraError: error, + extra: nil, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + func fatal( + _ message: @autoclosure @escaping () -> String, + _ error: Error? = nil, + _ extra: [String : Any], + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .fatal, + message: .string(message), + extraError: error, + extra: extra, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } +} diff --git a/Parra/Sessions/Logger/Logger+StaticLevels.swift b/Parra/Sessions/Logger/Logger+StaticLevels.swift new file mode 100644 index 000000000..1c7471fc6 --- /dev/null +++ b/Parra/Sessions/Logger/Logger+StaticLevels.swift @@ -0,0 +1,725 @@ +// +// Logger+StaticLevels.swift +// Parra +// +// Created by Mick MacCallum on 7/7/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +// +// +// Heads up! This file contains a whole bunch of duplicated code that can't really be avoided. +// Here we provide a series of instance methods for the Logger that allow for logging at +// different levels and providing different parameters. We had to provide multiple overloads +// to keep these easy to use, and each requires capturing thread and call site information, +// which can not be abstracted out without captuing incorrect data. +// +// + +import Foundation + +// NOTE: All messages/errors/extras are wrapped in auto closures to prevent them from being evaluated +// until we're sure that the log will actually be displayed. If we ever consider adding more overloads +// that allow passing a closure directly, we will need consideration around the fact that it is +// possible that the closure may be executed more than once. For example, for a log that is printed to +// the console, but later has its message accessed during a measurement. This may be unexpected for users +// of the Logger if they added code that produced side effects in any of these closures. + +public extension Logger { + @discardableResult + @inlinable + static func trace( + _ message: @autoclosure @escaping () -> String, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + return logToBackend( + level: .trace, + message: .string(message), + extra: nil, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + static func trace( + _ message: @autoclosure @escaping () -> String, + _ extra: [String : Any], + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + return logToBackend( + level: .trace, + message: .string(message), + extra: extra, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + static func debug( + _ message: @autoclosure @escaping () -> String, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + return logToBackend( + level: .debug, + message: .string(message), + extra: nil, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + static func debug( + _ message: @autoclosure @escaping () -> String, + _ extra: [String : Any], + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + return logToBackend( + level: .debug, + message: .string(message), + extra: extra, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + static func info( + _ message: @autoclosure @escaping () -> String, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + return logToBackend( + level: .info, + message: .string(message), + extra: nil, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + static func info( + _ message: @autoclosure @escaping () -> String, + _ extra: [String : Any], + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + return logToBackend( + level: .info, + message: .string(message), + extra: extra, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + static func warn( + _ message: @autoclosure @escaping () -> String, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + return logToBackend( + level: .warn, + message: .string(message), + extra: nil, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + static func warn( + _ message: @autoclosure @escaping () -> String, + _ extra: [String : Any], + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + return logToBackend( + level: .warn, + message: .string(message), + extra: extra, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + static func error( + _ message: @autoclosure @escaping () -> String, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .error, + message: .string(message), + extra: nil, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + static func error( + _ message: @autoclosure @escaping () -> String, + _ extra: [String : Any], + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .error, + message: .string(message), + extra: extra, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + static func error( + _ error: @autoclosure @escaping () -> ParraError, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .error, + message: .error(error), + extra: nil, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + static func error( + _ error: @autoclosure @escaping () -> ParraError, + _ extra: [String : Any], + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .error, + message: .error(error), + extra: extra, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + static func error( + _ error: @autoclosure @escaping () -> Error, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .error, + message: .error(error), + extra: nil, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + static func error( + _ error: @autoclosure @escaping () -> Error, + _ extra: [String : Any], + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .error, + message: .error(error), + extra: extra, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + static func error( + _ message: @autoclosure @escaping () -> String, + _ error: Error? = nil, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .error, + message: .string(message), + extraError: error, + extra: nil, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + static func error( + _ message: @autoclosure @escaping () -> String, + _ error: Error? = nil, + _ extra: [String : Any], + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .error, + message: .string(message), + extraError: error, + extra: extra, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + static func fatal( + _ message: @autoclosure @escaping () -> String, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .fatal, + message: .string(message), + extra: nil, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + static func fatal( + _ message: @autoclosure @escaping () -> String, + _ extra: [String : Any], + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .fatal, + message: .string(message), + extra: extra, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + static func fatal( + _ error: @autoclosure @escaping () -> Error, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .fatal, + message: .error(error), + extra: nil, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + static func fatal( + _ error: @autoclosure @escaping () -> Error, + _ extra: [String : Any], + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .fatal, + message: .error(error), + extra: extra, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + static func fatal( + _ message: @autoclosure @escaping () -> String, + _ error: Error? = nil, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .fatal, + message: .string(message), + extraError: error, + extra: nil, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } + + @discardableResult + @inlinable + static func fatal( + _ message: @autoclosure @escaping () -> String, + _ error: Error? = nil, + _ extra: [String : Any], + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarker { + // Call stack symbols must be captured directly within the body of these methods + // to avoid capturing additional frames. We also drop the first frame because it + // will always be the logger method. This means this can't be refactored to + // deduplicate this logic. + let callStackSymbols = Array(Thread.callStackSymbols.dropFirst(1)) + let threadInfo = ParraLoggerThreadInfo( + thread: .current, + callStackSymbols: .raw(callStackSymbols) + ) + + return logToBackend( + level: .fatal, + message: .string(message), + extraError: error, + extra: extra, + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + } +} diff --git a/Parra/Sessions/Logger/Logger+Timers.swift b/Parra/Sessions/Logger/Logger+Timers.swift new file mode 100644 index 000000000..f5b5fa1ec --- /dev/null +++ b/Parra/Sessions/Logger/Logger+Timers.swift @@ -0,0 +1,179 @@ +// +// Logger+Timers.swift +// Parra +// +// Created by Mick MacCallum on 7/6/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +public extension Logger { + // TODO: It is possible that multiple markers returned from `time()` functions could be chained together to measure + // the times between multiple events. It would be nice to detect that a start marker was itself a measurement + // against its own start marker and output in a format that allowed you to see the entire sequence of measurements. + + + /// Measures the time since the start marker was created and then prints a message indicating how long the action took. + /// + /// - Parameters: + /// - startMarker: The marker that is being measured against. + /// - message: A custom message that you want to provide to be displayed before the between now and the `startMarker`. + /// If no message is provided, we will use the message that was attached to the log that created the + /// `startMarker`. + /// - format: The format that the duration since the `startMarker` should be displayed in. Options include: + /// * `seconds` (e.x. "70 seconds" + /// * `pretty` (e.x. "1 minute, 10 seconds) + /// * `custom` This option allows you to pass a `DateComponentsFormatter`, giving you complete control + /// over the output format. + /// If an error is encountered formatting the output, we fall back on the `seconds` style. + /// - Returns: A ``ParraLogMarkerMeasurement`` containing data about the measurement, that can be useful if you want + /// to record any of this information in your own systems. This return value is discardable. + @discardableResult + static func measureTime( + since startMarker: ParraLogMarker, + message: String? = nil, + format: ParraLogMeasurementFormat = .pretty, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarkerMeasurement { + let endDate = Date.now // Should be the very first line. + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + let callSiteContext = ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + + let timeInterval = endDate.timeIntervalSince(startMarker.timestamp) + + // If the user provided a custom message, use it. Otherwise use the message that was attached to the + // start marker + let messageProvider = createMessageProvider( + for: message ?? { startMarker.message.produceLog().0 }(), + with: timeInterval, + in: format + ) + + let lazyMessage = ParraLazyLogParam.string(messageProvider) + + // Context is tricky here, because there is a case where a marker created by a logger instance with + // more context is passed to the static time measurement method, which lacks the same context. + + let nextMarker = logToBackend( + level: .info, + message: lazyMessage, + callSiteContext: callSiteContext + ) + + return ParraLogMarkerMeasurement( + timeInterval: timeInterval, + nextMarker: nextMarker + ) + } + + /// Measures the time since the start marker was created and then prints a message indicating how long the action took. + /// + /// - Parameters: + /// - startMarker: The marker that is being measured against. + /// - message: A custom message that you want to provide to be displayed before the between now and the `startMarker`. + /// If no message is provided, we will use the message that was attached to the log that created the + /// `startMarker`. + /// - format: The format that the duration since the `startMarker` should be displayed in. Options include: + /// * `seconds` (e.x. "70 seconds" + /// * `pretty` (e.x. "1 minute, 10 seconds) + /// * `custom` This option allows you to pass a `DateComponentsFormatter`, giving you complete control + /// over the output format. + /// If an error is encountered formatting the output, we fall back on the `seconds` style. + /// - Returns: A ``ParraLogMarkerMeasurement`` containing data about the measurement, that can be useful if you want + /// to record any of this information in your own systems. This return value is discardable. + @discardableResult + func measureTime( + since startMarker: ParraLogMarker, + message: String? = nil, + format: ParraLogMeasurementFormat = .pretty, + _ fileId: String = #fileID, + _ function: String = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ParraLogMarkerMeasurement { + let endDate = Date.now + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + let callSiteContext = ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + + let timeInterval = endDate.timeIntervalSince(startMarker.timestamp) + + // If the user provided a custom message, use it. Otherwise use the message that was attached to the + // start marker + let messageProvider = Logger.createMessageProvider( + for: message ?? { startMarker.message.produceLog().0 }(), + with: timeInterval, + in: format + ) + + let lazyMessage = ParraLazyLogParam.string(messageProvider) + let nextMarker = logToBackend( + level: .info, + message: lazyMessage, + callSiteContext: callSiteContext + ) + + return ParraLogMarkerMeasurement( + timeInterval: timeInterval, + nextMarker: nextMarker + ) + } + + private static func createMessageProvider( + for eventName: String, + with timeInterval: TimeInterval, + in format: ParraLogMeasurementFormat + ) -> () -> String { + return { + + if timeInterval < 60 { + let formatted = timeInterval.formatted(.number.precision(.fractionLength(4))) + return "\(formatted) second(s)" + } + + var formatter = Parra.InternalConstants.Formatters.dateComponentsFormatter + + formatter.formattingContext = .middleOfSentence + formatter.includesApproximationPhrase = false + formatter.unitsStyle = .full + formatter.allowsFractionalUnits = true + + switch format { + case .seconds: + formatter.allowedUnits = [.second] + case .pretty: + formatter.allowedUnits = [.second, .minute, .hour] + formatter.collapsesLargestUnit = true + case .custom(let customFormatter): + formatter = customFormatter + } + + let formattedInterval = formatter.string( + from: timeInterval + ) ?? "\(timeInterval) second(s)" + + return "\(eventName) took \(formattedInterval)" + } + } +} diff --git a/Parra/Sessions/Logger/Logger+scope.swift b/Parra/Sessions/Logger/Logger+scope.swift new file mode 100644 index 000000000..0280e8171 --- /dev/null +++ b/Parra/Sessions/Logger/Logger+scope.swift @@ -0,0 +1,306 @@ +// +// Logger+scope.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +public extension Logger { + + /// Creates a new scope with the provided name but does not immediately enter it. This scope can be used + /// to group multiple logs relevant to a given action. You can also automatically encapsulate a block of code + /// within a scope by using ``Logger/withScope(named:_:_:)-29mab`` + func scope( + named name: String? = nil, + _ function: String = #function + ) -> Logger { + // TODO: Maybe capture call site info here and use it to add scope entered/exited logs + return scope(named: name, nil, function) + } + + /// Creates a new scope with the provided name but does not immediately enter it. This scope can be used + /// to group multiple logs relevant to a given action. You can also automatically encapsulate a block of code + /// within a scope by using ``Logger/withScope(named:_:_:)-29mab`` + func scope( + named name: String? = nil, + _ extra: [String : Any]?, + _ function: String = #function + ) -> Logger { + let scopes: [ParraLoggerScopeType] + if let name { + scopes = [.customName(name), .function(function)] + } else { + scopes = [.function(function)] + } + + return Logger( + parent: self, + context: context.addingScopes( + scopes: scopes, + extra: extra + ) + ) + } + + /// Creates a new scope for the logger with the provided `name`. This scope will be entered before executing + /// the `block` function and will exit once the execution of `block` has completed. Any errors thrown + /// as a result of executing `block` are automatically logged and rethrown. The value returned by `block` + /// will be returned from the `withScope` function. + func withScope( + named name: String? = nil, + _ block: (_ logger: Logger) -> T, + _ function: String = #function + ) -> T { + return withScope(named: name, nil, block, function) + } + + /// Creates a new scope for the logger with the provided `name`. This scope will be entered before executing + /// the `block` function and will exit once the execution of `block` has completed. Any errors thrown + /// as a result of executing `block` are automatically logged and rethrown. The value returned by `block` + /// will be returned from the `withScope` function. + func withScope( + named name: String? = nil, + _ extra: [String : Any]?, + _ block: (_ logger: Logger) -> T, + _ function: String = #function + ) -> T { + let scoped = scope(named: name, extra, function) + + return block(scoped) + } + + /// Creates a new scope for the logger with the provided `name`. This scope will be entered before executing + /// the `block` function and will exit once the execution of `block` has completed. Any errors thrown + /// as a result of executing `block` are automatically logged and rethrown. The value returned by `block` + /// will be returned from the `withScope` function. + func withScope( + named name: String? = nil, + _ block: (_ logger: Logger) throws -> T, + _ function: String = #function + ) throws -> T { + return try withScope(named: name, nil, block, function) + } + + /// Creates a new scope for the logger with the provided `name`. This scope will be entered before executing + /// the `block` function and will exit once the execution of `block` has completed. Any errors thrown + /// as a result of executing `block` are automatically logged and rethrown. The value returned by `block` + /// will be returned from the `withScope` function. + func withScope( + named name: String? = nil, + _ extra: [String : Any]?, + _ block: (_ logger: Logger) throws -> T, + _ function: String = #function + ) rethrows -> T { + let scoped = scope(named: name, extra, function) + + do { + return try block(scoped) + } catch let error { + scoped.error(error) + throw error + } + } + + /// Creates a new scope for the logger with the provided `name`. This scope will be entered before executing + /// the `block` function and will exit once the execution of `block` has completed. Any errors thrown + /// as a result of executing `block` are automatically logged and rethrown. The value returned by `block` + /// will be returned from the `withScope` function. + func withScope( + named name: String? = nil, + _ block: (_ logger: Logger) async throws -> T, + _ function: String = #function + ) async throws -> T { + return try await withScope(named: name, nil, block, function) + } + + /// Creates a new scope for the logger with the provided `name`. This scope will be entered before executing + /// the `block` function and will exit once the execution of `block` has completed. Any errors thrown + /// as a result of executing `block` are automatically logged and rethrown. The value returned by `block` + /// will be returned from the `withScope` function. + func withScope( + named name: String? = nil, + _ extra: [String : Any]?, + _ block: (_ logger: Logger) async throws -> T, + _ function: String = #function + ) async rethrows -> T { + let scoped = scope(named: name, extra, function) + + do { + return try await block(scoped) + } catch let error { + scoped.error(error) + throw error + } + } + + /// Creates a new scope for the logger with the provided `name`. This scope will be entered before executing + /// the `block` function and will exit once the execution of `block` has completed. Any errors thrown + /// as a result of executing `block` are automatically logged and rethrown. The value returned by `block` + /// will be returned from the `withScope` function. + func withScope( + named name: String? = nil, + _ block: (_ logger: Logger) async -> T, + _ function: String = #function + ) async -> T { + return await withScope(named: name, nil, block, function) + } + + /// Creates a new scope for the logger with the provided `name`. This scope will be entered before executing + /// the `block` function and will exit once the execution of `block` has completed. Any errors thrown + /// as a result of executing `block` are automatically logged and rethrown. The value returned by `block` + /// will be returned from the `withScope` function. + func withScope( + named name: String? = nil, + _ extra: [String : Any]?, + _ block: (_ logger: Logger) async -> T, + _ function: String = #function + ) async -> T { + let scoped = scope(named: name, extra, function) + + return await block(scoped) + } + + /// Creates a new scope with the provided name but does not immediately enter it. This scope can be used + /// to group multiple logs relevant to a given action. You can also automatically encapsulate a block of code + /// within a scope by using ``Logger/withScope(named:_:_:)-29mab`` + static func scope( + named name: String? = nil, + _ function: String = #function + ) -> Logger { + // TODO: Maybe capture call site info here and use it to add scope entered/exited logs + return scope(named: name, nil, function) + } + + /// Creates a new scope with the provided name but does not immediately enter it. This scope can be used + /// to group multiple logs relevant to a given action. You can also automatically encapsulate a block of code + /// within a scope by using ``Logger/withScope(named:_:_:)-29mab`` + static func scope( + named name: String? = nil, + _ extra: [String : Any]?, + _ function: String = #function + ) -> Logger { + return Logger(category: name, extra: extra) + .scope(named: nil, function) + } + + /// Creates a new scope for the logger with the provided `name`. This scope will be entered before executing + /// the `block` function and will exit once the execution of `block` has completed. Any errors thrown + /// as a result of executing `block` are automatically logged and rethrown. The value returned by `block` + /// will be returned from the `withScope` function. + static func withScope( + named name: String? = nil, + _ block: (_ logger: Logger) -> T, + _ function: String = #function + ) -> T { + return withScope(named: name, nil, block, function) + } + + /// Creates a new scope for the logger with the provided `name`. This scope will be entered before executing + /// the `block` function and will exit once the execution of `block` has completed. Any errors thrown + /// as a result of executing `block` are automatically logged and rethrown. The value returned by `block` + /// will be returned from the `withScope` function. + static func withScope( + named name: String? = nil, + _ extra: [String : Any]?, + _ block: (_ logger: Logger) -> T, + _ function: String = #function + ) -> T { + let scoped = scope(named: name, extra, function) + + return block(scoped) + } + + /// Creates a new scope for the logger with the provided `name`. This scope will be entered before executing + /// the `block` function and will exit once the execution of `block` has completed. Any errors thrown + /// as a result of executing `block` are automatically logged and rethrown. The value returned by `block` + /// will be returned from the `withScope` function. + static func withScope( + named name: String? = nil, + _ block: (_ logger: Logger) throws -> T, + _ function: String = #function + ) throws -> T { + return try withScope(named: name, nil, block, function) + } + + /// Creates a new scope for the logger with the provided `name`. This scope will be entered before executing + /// the `block` function and will exit once the execution of `block` has completed. Any errors thrown + /// as a result of executing `block` are automatically logged and rethrown. The value returned by `block` + /// will be returned from the `withScope` function. + static func withScope( + named name: String? = nil, + _ extra: [String : Any]?, + _ block: (_ logger: Logger) throws -> T, + _ function: String = #function + ) rethrows -> T { + let scoped = scope(named: name, extra, function) + + do { + return try block(scoped) + } catch let error { + scoped.error(error) + throw error + } + } + + /// Creates a new scope for the logger with the provided `name`. This scope will be entered before executing + /// the `block` function and will exit once the execution of `block` has completed. Any errors thrown + /// as a result of executing `block` are automatically logged and rethrown. The value returned by `block` + /// will be returned from the `withScope` function. + static func withScope( + named name: String? = nil, + _ block: (_ logger: Logger) async throws -> T, + _ function: String = #function + ) async throws -> T { + return try await withScope(named: name, nil, block, function) + } + + /// Creates a new scope for the logger with the provided `name`. This scope will be entered before executing + /// the `block` function and will exit once the execution of `block` has completed. Any errors thrown + /// as a result of executing `block` are automatically logged and rethrown. The value returned by `block` + /// will be returned from the `withScope` function. + static func withScope( + named name: String? = nil, + _ extra: [String : Any]?, + _ block: (_ logger: Logger) async throws -> T, + _ function: String = #function + ) async rethrows -> T { + let scoped = scope(named: name, extra, function) + + do { + return try await block(scoped) + } catch let error { + scoped.error(error) + throw error + } + } + + /// Creates a new scope for the logger with the provided `name`. This scope will be entered before executing + /// the `block` function and will exit once the execution of `block` has completed. Any errors thrown + /// as a result of executing `block` are automatically logged and rethrown. The value returned by `block` + /// will be returned from the `withScope` function. + static func withScope( + named name: String? = nil, + _ block: (_ logger: Logger) async -> T, + _ function: String = #function + ) async -> T { + return await withScope(named: name, nil, block, function) + } + + /// Creates a new scope for the logger with the provided `name`. This scope will be entered before executing + /// the `block` function and will exit once the execution of `block` has completed. Any errors thrown + /// as a result of executing `block` are automatically logged and rethrown. The value returned by `block` + /// will be returned from the `withScope` function. + static func withScope( + named name: String? = nil, + _ extra: [String : Any]?, + _ block: (_ logger: Logger) async -> T, + _ function: String = #function + ) async -> T { + let scoped = scope(named: name, extra, function) + + return await block(scoped) + } +} diff --git a/Parra/Sessions/Logger/Logger.swift b/Parra/Sessions/Logger/Logger.swift new file mode 100644 index 000000000..226b19755 --- /dev/null +++ b/Parra/Sessions/Logger/Logger.swift @@ -0,0 +1,229 @@ +// +// Logger.swift +// Parra +// +// Created by Mick MacCallum on 7/5/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation +import os + +public class Logger { + private struct Constant { + /// The maximum number of logs that can be collected before a logging backend is configured. + /// Once the backend is set, the most recent logs (number by this variable) will be flushed to the backend. + static let maxOrphanedLogBuffer = 100 + } + + /// A backend for all the methods for different verbosities provided by the Parra + /// Logger. The logger backend is the place where log events should be written + /// to console, disk, network, etc. + internal static var loggerBackend: ParraLoggerBackend? { + didSet { + loggerBackendDidChange() + } + } + + internal let context: ParraLoggerContext + internal private(set) weak var parent: Logger? + + /// Whether or not logging is enabled on this logger instance. Logging is enabled + /// by default. If you disable logging, logs are ignored until re-enabling. Changing this + /// property will not affect any logs made before enabling/disabling the logger. + public var isEnabled = true + + /// Wether logs written to this instance of the Logger should bypass being written to events + /// for the session. This should always default to false and only be allowed to be enabled internally. + internal private(set) var bypassEventCreation: Bool + + /// A cache of logs that occurred before the Parra SDK was initialized. Once initialization occurs + /// these are meant to be flushed to the newly set logger backend. + private static let cachedLogsLock = OSAllocatedUnfairLock(initialState: [ParraLogData]()) + + internal let fiberId: String? + + public init( + category: String? = nil, + extra: [String : Any]? = nil, + fileId: String = #fileID, + function: String = #function + ) { + bypassEventCreation = false + fiberId = UUID().uuidString + + context = ParraLoggerContext( + fiberId: fiberId, + fileId: fileId, + category: category, + scope: .function(function), + extra: extra ?? [:] + ) + } + + internal init( + bypassEventCreation: Bool, + category: String? = nil, + extra: [String : Any]? = nil, + fileId: String = #fileID + ) { + self.bypassEventCreation = bypassEventCreation + self.fiberId = UUID().uuidString + self.context = ParraLoggerContext( + fiberId: fiberId, + fileId: fileId, + category: category, + scopes: [], + extra: extra + ) + } + + internal init( + parent: Logger, + context: ParraLoggerContext + ) { + self.bypassEventCreation = parent.bypassEventCreation + self.fiberId = parent.fiberId + self.context = context + self.parent = parent + } + + @usableFromInline + internal func logToBackend( + level: ParraLogLevel, + message: ParraLazyLogParam, + extraError: Error? = nil, + extra: [String : Any]? = nil, + callSiteContext: ParraLoggerCallSiteContext + ) -> ParraLogMarker { + let timestamp = Date.now + let logContext = ParraLogContext( + callSiteContext: callSiteContext, + loggerContext: context, + bypassEventCreation: bypassEventCreation + ) + + guard isEnabled else { + return ParraLogMarker( + timestamp: timestamp, + message: message, + initialLevel: level, + initialLogContext: logContext + ) + } + + let data = ParraLogData( + timestamp: timestamp, + level: level, + message: message, + extraError: extraError, + extra: extra, + logContext: logContext + ) + + if let loggerBackend = Logger.loggerBackend { + loggerBackend.log( + data: data + ) + } else { + Logger.collectLogWithoutDestination(data: data) + } + + return ParraLogMarker( + timestamp: timestamp, + message: message, + initialLevel: level, + initialLogContext: logContext + ) + } + + @usableFromInline + internal static func logToBackend( + level: ParraLogLevel, + message: ParraLazyLogParam, + extraError: Error? = nil, + extra: [String : Any]? = nil, + callSiteContext: ParraLoggerCallSiteContext + ) -> ParraLogMarker { + let timestamp = Date.now + let logContext = ParraLogContext( + callSiteContext: callSiteContext, + loggerContext: nil, + // Static logger methods do not have a way of changing this configuration (intentionally). + bypassEventCreation: false + ) + + let data = ParraLogData( + timestamp: timestamp, + level: level, + message: message, + extraError: extraError, + extra: extra, + logContext: logContext + ) + + // We don't check that the logger is enabled here because this only applies to + // logger instances. + if let loggerBackend { + loggerBackend.log( + data: data + ) + } else { + collectLogWithoutDestination(data: data) + } + + return ParraLogMarker( + timestamp: timestamp, + message: message, + initialLevel: level, + initialLogContext: logContext + ) + } + + /// Enabling logging on this logger instance. This update only applies to the current Logger + /// instance and not to other instances or static Logger.info/etc methods. Logging is enabled by default. + public func enable() { + isEnabled = true + } + + /// Disabling logging on this logger instance. This update only applies to the current Logger + /// instance and not to other instances or static Logger.info/etc methods. Logging is enabled by default. + public func disable() { + isEnabled = false + } + + private static func loggerBackendDidChange() { + cachedLogsLock.withLock { cachedLogs in + guard let loggerBackend else { + // If the logger backend is unset or still not set, there is nowhere to flush the logs to. + return + } + + if cachedLogs.isEmpty { + // Nothing to do with no cached logs + return + } + + // Copy and clear the cache since processing happens asynchronously + let logCacheCopy = cachedLogs + cachedLogs = [] + + loggerBackend.logMultiple( + data: logCacheCopy + ) + } + } + + private static func collectLogWithoutDestination( + data: ParraLogData + ) { + cachedLogsLock.withLock { cachedLogs in + cachedLogs.append(data) + + if cachedLogs.count > Constant.maxOrphanedLogBuffer { + let numToDrop = cachedLogs.count - Constant.maxOrphanedLogBuffer + cachedLogs = Array(cachedLogs.dropFirst(numToDrop)) + } + } + } +} diff --git a/Parra/Sessions/Logger/LoggerFormatters.swift b/Parra/Sessions/Logger/LoggerFormatters.swift new file mode 100644 index 000000000..8cb71f158 --- /dev/null +++ b/Parra/Sessions/Logger/LoggerFormatters.swift @@ -0,0 +1,67 @@ +// +// LoggerFormatters.swift +// Parra +// +// Created by Mick MacCallum on 8/14/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal struct LoggerFormatters { + + internal static func extractMessage( + from error: Error + ) -> (message: String, extra: [String : Any]?) { + + // Errors when JSON encoding/decoding fails + if let encodingError = error as? EncodingError { + switch encodingError { + case .invalidValue(let value, let context): + let aggregatePath = context.codingPath.map { path in + if let intValue = path.intValue { + return "[\(intValue)]" + } + + return path.stringValue + }.joined(separator: ".") + + var extra: [String : Any] = [ + "coding_path": context.codingPath + ] + + if let underlyingError = context.underlyingError { + extra["underlying_error"] = underlyingError.localizedDescription + } + + return ( + message: "\(context.debugDescription). Keypath: \(aggregatePath) Error: \(value)", + extra: nil + ) + @unknown default: + break + } + } + + // Error is always bridged to NSError, can't downcast to check. + if type(of: error) is NSError.Type { + let nsError = error as NSError + + var extra = nsError.userInfo + extra["domain"] = nsError.domain + extra["code"] = nsError.code + + return ( + message: nsError.localizedDescription, + extra: extra + ) + } + + // It is important to include a reflection of Error conforming types in order to actually identify which + // error enum they belond to. This information is not provided by their descriptions. + return ( + message: "\(String(reflecting: error)), description: \(error.localizedDescription)", + extra: nil + ) + } +} diff --git a/Parra/Sessions/Logger/LoggerHelpers.swift b/Parra/Sessions/Logger/LoggerHelpers.swift new file mode 100644 index 000000000..628a81baf --- /dev/null +++ b/Parra/Sessions/Logger/LoggerHelpers.swift @@ -0,0 +1,115 @@ +// +// LoggerHelpers.swift +// Parra +// +// Created by Mick MacCallum on 6/25/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation +import Darwin + +internal typealias SplitFileId = ( + module: String, + fileName: String, + fileExtension: String? +) + +internal struct LoggerHelpers { + + /// Useful for converting various types of "Error" into an actual readable message. By default + /// Error conforming types will not display what type of error they actually are in their + /// localizedDescription, for example. + internal static func extractMessageAndExtra( + from error: Error + ) -> ParraErrorWithExtra { + if let parraError = error as? ParraError { + return ParraErrorWithExtra(parraError: parraError) + } else { + return ParraErrorWithExtra(error: error) + } + } + + /// Whether or not the file id is from a call site within the Parra module. + internal static func isFileIdInternal(fileId: String) -> Bool { + let (module, _) = splitFileId(fileId: fileId) + + return module == Parra.name + } + + /// Safely splits a file id (#fileID) into a module name, a file name and a file extension. + internal static func splitFileId( + fileId: String + ) -> SplitFileId { + // TODO: Maybe this should be cached since it will be accessed frequently. + + let (module, fileName) = splitFileId(fileId: fileId) + + let fileParts = fileName.split(separator: ".") + let components: SplitFileId + if fileParts.count == 1 { + // No file extension found + components = (module, fileName, nil) + } else { + // Handles cases where file extensions have multiple periods. + components = (module, String(fileParts[0]), fileParts.dropFirst(1).joined(separator: ".")) + } + + return components + } + + /// Safely splits a file id (#fileID) into a module name, and a file name, with extension. + /// E.x. Demo/AppDelegate.swift (same if logger is top level) + internal static func splitFileId( + fileId: String + ) -> (module: String, fileName: String) { + let parts = fileId.split(separator: "/") + + if parts.count == 0 { + return ("Unknown", "Unknown") + } else if parts.count == 1 { + return ("Unknown", String(parts[0])) + } else if parts.count == 2 { + return (String(parts[0]), String(parts[1])) + } else { + return (String(parts[0]), parts.dropFirst(1).joined(separator: "/")) + } + } + + /// Generates a slug representative of a callsite. + internal static func createFormattedLocation( + fileId: String, + function: String, + line: Int + ) -> String { + let file: String + if let extIndex = fileId.lastIndex(of: ".") { + file = String(fileId[.. String? { + var info = Dl_info() + + guard dladdr(dynamicObjectHandle, &info) != 0 else { + return nil + } + + return String(cString: info.dli_fname) + } +} diff --git a/Parra/Sessions/Logger/ParraLazyLogParam.swift b/Parra/Sessions/Logger/ParraLazyLogParam.swift new file mode 100644 index 000000000..abd8de6bd --- /dev/null +++ b/Parra/Sessions/Logger/ParraLazyLogParam.swift @@ -0,0 +1,28 @@ +// +// ParraLazyLogParam.swift +// Parra +// +// Created by Mick MacCallum on 6/24/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +@usableFromInline +internal enum ParraLazyLogParam { + case string(() -> String) + case error(() -> Error) + + internal func produceLog() -> (String, [String : Any]?) { + switch self { + case .string(let messageProvider): + return (messageProvider(), nil) + case .error(let errorProvider): + let errorWithExtra = LoggerHelpers.extractMessageAndExtra( + from: errorProvider() + ) + + return (errorWithExtra.message, errorWithExtra.extra) + } + } +} diff --git a/Parra/Sessions/Logger/Types/Context/ParraLogContext.swift b/Parra/Sessions/Logger/Types/Context/ParraLogContext.swift new file mode 100644 index 000000000..540adbc15 --- /dev/null +++ b/Parra/Sessions/Logger/Types/Context/ParraLogContext.swift @@ -0,0 +1,27 @@ +// +// ParraLogContext.swift +// Parra +// +// Created by Mick MacCallum on 9/3/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +/// A context object representative of a log itself. +internal struct ParraLogContext { + /// Context around where the actual log came from. + internal let callSiteContext: ParraLoggerCallSiteContext + + /// Context related to the logger that the log was made with. + /// + /// It is possible that the context for a give log is missing the context of the logger that generated + /// the log. For example, in cases where static logger methods are used. + internal let loggerContext: ParraLoggerContext? + + /// Whether or not this log should be forced to bypass normal rules for when it should be + /// processed as a log that is output through the console vs. being written as an event. + /// This is for internal use only, and only in cases where creating events in response to the + /// creation of a log is a recursive operation. Like failures within the session storage modules. + internal let bypassEventCreation: Bool +} diff --git a/Parra/Sessions/Logger/Types/Context/ParraLoggerCallSiteContext.swift b/Parra/Sessions/Logger/Types/Context/ParraLoggerCallSiteContext.swift new file mode 100644 index 000000000..a6f22bb97 --- /dev/null +++ b/Parra/Sessions/Logger/Types/Context/ParraLoggerCallSiteContext.swift @@ -0,0 +1,49 @@ +// +// ParraLoggerCallSiteContext.swift +// Parra +// +// Created by Mick MacCallum on 7/6/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +@usableFromInline +internal struct ParraLoggerCallSiteContext { + // fileId is used in place of Swift < 5.8 #file or #filePath to not + // expose sensitive information from full file paths. + internal let fileId: String + internal let function: String + internal let line: Int + internal let column: Int + + /// Must be passed in from the call site to ensure that information about the correct thread + /// is captured, and that we don't capture stack frames from within the Parra Logger, thus + /// potentially omitting important context. + internal var threadInfo: ParraLoggerThreadInfo + + @usableFromInline + internal init( + fileId: String, + function: String, + line: Int, + column: Int, + threadInfo: ParraLoggerThreadInfo + ) { + self.fileId = fileId + self.function = function + self.line = line + self.column = column + self.threadInfo = threadInfo + } + + internal var simpleFunctionName: String { + let components = function.split(separator: "(") + + guard let first = components.first else { + return function + } + + return String(first) + } +} diff --git a/Parra/Sessions/Logger/Types/Context/ParraLoggerContext.swift b/Parra/Sessions/Logger/Types/Context/ParraLoggerContext.swift new file mode 100644 index 000000000..e912fecec --- /dev/null +++ b/Parra/Sessions/Logger/Types/Context/ParraLoggerContext.swift @@ -0,0 +1,148 @@ +// +// ParraLoggerContext.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +/// A context object representative of the logger and the state that it's in at the +/// time when a log is created. +internal struct ParraLoggerContext { + internal let fiberId: String? + + // Context items in order of precedence + internal let module: String + internal let fileName: String + internal let fileExtension: String? + + // This is the name provided when creating a logger instance. It will be the name of the class/etc + // that a logger is an instance variable of, in cases where we auto-create them. Some cases, like + // static logger methods, will not have a category defined. + internal let category: String? + + // Think of scopes as subcategories, which allow for arbitrary nesting + internal let scopes: [ParraLoggerScopeType] + + internal let extra: [String : Any]? + + internal init( + fiberId: String?, + fileId: String, + category: String?, + scope: ParraLoggerScopeType, + extra: [String : Any]? + ) { + let (module, fileName, fileExtension) = LoggerHelpers.splitFileId( + fileId: fileId + ) + + let scopes: [ParraLoggerScopeType] + if case let .function(rawName) = scope, rawName == module { + // If a function scope is present, and it has the same name as the module + // it means the logger instance was created at top level in a file, and + // this function scope is garbage data. + scopes = [] + } else { + scopes = [scope] + } + + self.fiberId = fiberId + self.module = module + self.fileName = fileName + self.fileExtension = fileExtension + self.category = category + self.scopes = scopes + self.extra = extra + } + + internal init( + fiberId: String?, + fileId: String, + category: String?, + scopes: [ParraLoggerScopeType], + extra: [String : Any]? + ) { + let (module, fileName, fileExtension) = LoggerHelpers.splitFileId( + fileId: fileId + ) + + self.fiberId = fiberId + self.module = module + self.fileName = fileName + self.fileExtension = fileExtension + self.category = category + self.scopes = scopes + self.extra = extra + } + + internal init( + fiberId: String?, + module: String, + fileName: String, + fileExtension: String?, + category: String?, + scopes: [ParraLoggerScopeType], + extra: [String : Any]? + ) { + self.fiberId = fiberId + self.module = module + self.fileName = fileName + self.fileExtension = fileExtension + self.category = category + self.scopes = scopes + self.extra = extra + } + + internal func addingScopes( + scopes newScopes: [ParraLoggerScopeType], + extra newExtra: [String : Any]? = nil + ) -> ParraLoggerContext { + let mergedExtra: [String : Any]? + if let newExtra { + if let extra { + mergedExtra = extra.merging(newExtra) { $1 } + } else { + mergedExtra = newExtra + } + } else { + mergedExtra = extra + } + + var mergedScopes = [ParraLoggerScopeType]() + + let mergeIfNotDuplicate = { (scope: ParraLoggerScopeType) in + if !scopes.contains(scope) { + mergedScopes.append(scope) + } + } + + for scope in newScopes { + switch scope { + case .customName(let customScopeName): + // If the scope has a custom name, we have a special case to deduplicate it with the file + // name. There is a high probability that top level loggers will be given names that + // exactly match the name of the file. + if customScopeName != fileName { + mergeIfNotDuplicate(scope) + } + case .function: + // If it's a function name, it can be added to the scopes list, as long as it isn't a + // duplicate. + mergeIfNotDuplicate(scope) + } + } + + return ParraLoggerContext( + fiberId: fiberId, + module: module, + fileName: fileName, + fileExtension: fileExtension, + category: category, + scopes: mergedScopes, + extra: mergedExtra + ) + } +} diff --git a/Parra/Sessions/Logger/Types/Context/ParraLoggerScopeType.swift b/Parra/Sessions/Logger/Types/Context/ParraLoggerScopeType.swift new file mode 100644 index 000000000..635ec30e1 --- /dev/null +++ b/Parra/Sessions/Logger/Types/Context/ParraLoggerScopeType.swift @@ -0,0 +1,40 @@ +// +// ParraLoggerScopeType.swift +// Parra +// +// Created by Mick MacCallum on 9/3/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal enum ParraLoggerScopeType: Equatable { + case customName(String) + case function(String) + + var name: String { + switch self { + case .customName(let string): + return string + case .function(let string): + let components = string.split(separator: "(") + + guard let first = components.first else { + return string + } + + return String(first) + } + } + + static func == (lhs: ParraLoggerScopeType, rhs: ParraLoggerScopeType) -> Bool { + switch (lhs, rhs) { + case (.customName(let lhsName), .customName(let rhsName)): + return lhsName == rhsName + case (.function(let lhsFunction), .function(let rhsFunction)): + return lhsFunction == rhsFunction + default: + return false + } + } +} diff --git a/Parra/Sessions/Logger/Types/Level/ParraLogLevel+ConsoleOutput.swift b/Parra/Sessions/Logger/Types/Level/ParraLogLevel+ConsoleOutput.swift new file mode 100644 index 000000000..b66136560 --- /dev/null +++ b/Parra/Sessions/Logger/Types/Level/ParraLogLevel+ConsoleOutput.swift @@ -0,0 +1,62 @@ +// +// ParraLogLevel+ConsoleOutput.swift +// Parra +// +// Created by Mick MacCallum on 9/3/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal extension ParraLogLevel { + var name: String { + switch self { + case .trace: + return "TRACE" + case .debug: + return "DEBUG" + case .info: + return "INFO" + case .warn: + return "WARN" + case .error: + return "ERROR" + case .fatal: + return "FATAL" + } + } + + var symbol: String { + switch self { + case .trace: + return "🟣" + case .debug: + return "🔵" + case .info: + return "⚪️" + case .warn: + return "🟡" + case .error: + return "🔴" + case .fatal: + return "💀" + } + } + + var loggerDescription: String { + switch self { + case .trace: + return "trace" + case .debug: + return "debug" + case .info: + return "info" + case .warn: + return "warn" + case .error: + return "error" + case .fatal: + return "fatal" + } + } +} diff --git a/Parra/Sessions/Logger/Types/Level/ParraLogLevel.swift b/Parra/Sessions/Logger/Types/Level/ParraLogLevel.swift new file mode 100644 index 000000000..70c810e59 --- /dev/null +++ b/Parra/Sessions/Logger/Types/Level/ParraLogLevel.swift @@ -0,0 +1,61 @@ +// +// ParraLogLevel.swift +// Parra +// +// Created by Mick MacCallum on 2/17/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation +import os + +public enum ParraLogLevel: Int, Comparable, ParraLogStringConvertible { + case trace = 1 + case debug = 2 + case info = 4 + case warn = 8 + case error = 16 + case fatal = 32 + + public static let `default` = ParraLogLevel.info + + internal init?(name: String) { + switch name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "trace": + self = .trace + case "debug": + self = .debug + case "info": + self = .info + case "warn": + self = .warn + case "error": + self = .error + case "fatal": + self = .fatal + default: + return nil + } + } + + public static func < (lhs: ParraLogLevel, rhs: ParraLogLevel) -> Bool { + return lhs.rawValue < rhs.rawValue + } + + internal var requiresStackTraceCapture: Bool { + return self >= .error + } + + internal var osLogType: os.OSLogType { + switch self { + case .trace, .debug: + return .debug + case .info: + return .info + case .warn, .error: + return .error + case .fatal: + return .fault + } + } +} diff --git a/Parra/Sessions/Logger/Types/Options/ParraLoggerCallSiteStyleOptions.swift b/Parra/Sessions/Logger/Types/Options/ParraLoggerCallSiteStyleOptions.swift new file mode 100644 index 000000000..0e8f611d0 --- /dev/null +++ b/Parra/Sessions/Logger/Types/Options/ParraLoggerCallSiteStyleOptions.swift @@ -0,0 +1,26 @@ +// +// ParraLoggerCallSiteStyleOptions.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +public struct ParraLoggerCallSiteStyleOptions: OptionSet { + public static let `default`: ParraLoggerCallSiteStyleOptions = [ + .function, .line + ] + + public let rawValue: Int8 + + public static let thread = ParraLoggerCallSiteStyleOptions(rawValue: 1 << 0) + public static let function = ParraLoggerCallSiteStyleOptions(rawValue: 1 << 1) + public static let line = ParraLoggerCallSiteStyleOptions(rawValue: 1 << 2) + public static let column = ParraLoggerCallSiteStyleOptions(rawValue: 1 << 3) + + public init(rawValue: Int8) { + self.rawValue = rawValue + } +} diff --git a/Parra/Sessions/Logger/Types/Options/ParraLoggerConsoleFormatOption.swift b/Parra/Sessions/Logger/Types/Options/ParraLoggerConsoleFormatOption.swift new file mode 100644 index 000000000..ee83123de --- /dev/null +++ b/Parra/Sessions/Logger/Types/Options/ParraLoggerConsoleFormatOption.swift @@ -0,0 +1,17 @@ +// +// ParraLoggerConsoleFormatOption.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +public enum ParraLoggerConsoleFormatOption { + case printMessage(leftPad: String, rightPad: String) + case printTimestamps(style: ParraLoggerTimestampStyle, leftPad: String, rightPad: String) + case printLevel(style: ParraLoggerLevelStyle, leftPad: String, rightPad: String) + case printCallSite(options: ParraLoggerCallSiteStyleOptions, leftPad: String, rightPad: String) + case printExtras(style: ParraLoggerExtraStyle, leftPad: String, rightPad: String) +} diff --git a/Parra/Sessions/Logger/Types/Options/ParraLoggerExtraStyle.swift b/Parra/Sessions/Logger/Types/Options/ParraLoggerExtraStyle.swift new file mode 100644 index 000000000..9e7700b10 --- /dev/null +++ b/Parra/Sessions/Logger/Types/Options/ParraLoggerExtraStyle.swift @@ -0,0 +1,22 @@ +// +// ParraLoggerExtraStyle.swift +// Parra +// +// Created by Mick MacCallum on 9/3/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +public enum ParraLoggerExtraStyle { + public static let `default` = ParraLoggerExtraStyle.pretty + + // Extra dictionaries are printed using their default ``description``. + case raw + + // Similar to ``raw``, but will truncate long keys and values + case condensed + + // Easy to read, lots of whitespace, and indentation between different levels. + case pretty +} diff --git a/Parra/Sessions/Logger/Types/Options/ParraLoggerLevelStyle.swift b/Parra/Sessions/Logger/Types/Options/ParraLoggerLevelStyle.swift new file mode 100644 index 000000000..b60cce5f8 --- /dev/null +++ b/Parra/Sessions/Logger/Types/Options/ParraLoggerLevelStyle.swift @@ -0,0 +1,17 @@ +// +// ParraLoggerLevelStyle.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +public enum ParraLoggerLevelStyle { + public static let `default` = ParraLoggerLevelStyle.both + + case symbol + case word + case both +} diff --git a/Parra/Sessions/Logger/Types/Options/ParraLoggerOptions.swift b/Parra/Sessions/Logger/Types/Options/ParraLoggerOptions.swift new file mode 100644 index 000000000..5ab7c4536 --- /dev/null +++ b/Parra/Sessions/Logger/Types/Options/ParraLoggerOptions.swift @@ -0,0 +1,68 @@ +// +// ParraLoggerOptions.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +// TODO: Figure out how to get a default ParraLogMeasurementFormat in here. + +public struct ParraLoggerOptions { + public enum Environment { + public static let minimumLogLevelOverride = "PARRA_LOG_LEVEL" + public static let eventDebugLoggingEnabled = "PARRA_DEBUG_EVENT_LOGGING" + } + +#if swift(>=5.9) + // Xcode 15 (which ships with Swift 5.9) has a new console that doesn't require printing + // the log level or timestamp. + public static let `default` = ParraLoggerOptions( + environment: .automatic, + minimumLogLevel: .info, + consoleFormatOptions: [ + .printLevel(style: .symbol, leftPad: "", rightPad: " "), + .printCallSite(options: .default, leftPad: "[", rightPad: "]"), + .printMessage(leftPad: " ", rightPad: " "), + .printExtras(style: .pretty, leftPad: "\n", rightPad: "") + ] + ) +#else + public static let `default` = ParraLoggerOptions( + environment: .automatic, + minimumLogLevel: .info, + consoleFormatOptions: [ + .printLevel(style: .default, leftPad: "[", rightPad: "]"), + .printTimestamps(style: .default, leftPad: "[", rightPad: "]"), + .printMessage(leftPad: " ", rightPad: " "), + .printCallSite(options: .default, leftPad: "\t\t\t\t(", rightPad: ")"), + .printExtras(style: .condensed, leftPad: "\n", rightPad: "") + ] + ) +#endif + + /// Use the environment option to override the behavior for when Parra + /// logs are displayed in the console or uploaded to Parra. For more + /// information, see ``ParraLoggerEnvironment``. + public internal(set) var environment: ParraLoggerEnvironment + + /// Only logs at greater or equal severity will be kept. + /// + /// Order of precedance + /// 1. Value of environmental variable `PARRA_LOG_LEVEL`. + /// 2. The value specified by this option. + /// 3. The default log level, `info`. + public internal(set) var minimumLogLevel: ParraLogLevel + + /// For configurations where logs are output to the console instead of + /// being uploaded to Parra, use these options to specify what information + /// you'd like to be displayed. + /// + /// This includes whether or not to display data like timestamps, call site + /// information, or categories. The order that options are passed to this + /// array will be used to determine the order of their respective information + /// in the output string. + public internal(set) var consoleFormatOptions: [ParraLoggerConsoleFormatOption] +} diff --git a/Parra/Sessions/Logger/Types/Options/ParraLoggerTimestampStyle.swift b/Parra/Sessions/Logger/Types/Options/ParraLoggerTimestampStyle.swift new file mode 100644 index 000000000..c6eb5e891 --- /dev/null +++ b/Parra/Sessions/Logger/Types/Options/ParraLoggerTimestampStyle.swift @@ -0,0 +1,20 @@ +// +// ParraLoggerTimestampStyle.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +// TODO: Document samples of each + +public enum ParraLoggerTimestampStyle { + public static let `default` = ParraLoggerTimestampStyle.iso8601 + + case custom(DateFormatter) + case epoch + case iso8601 + case rfc3339 +} diff --git a/Parra/Sessions/Logger/Types/ParraLogData.swift b/Parra/Sessions/Logger/Types/ParraLogData.swift new file mode 100644 index 000000000..db31ae21a --- /dev/null +++ b/Parra/Sessions/Logger/Types/ParraLogData.swift @@ -0,0 +1,30 @@ +// +// ParraLogData.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +// TODO: Define and impose length limits for keys/values. + +internal struct ParraLogData { + let timestamp: Date + + let level: ParraLogLevel + + /// Messages should always be functions that return a message. This allows the logger + /// to only execute potentially expensive code if the logger is enabled. A wrapper object + /// is provided to help differentiate between log types. + let message: ParraLazyLogParam + + /// When a primary message is provided but there is still an error object attached to the log. + let extraError: Error? + + /// Any additional information that you're like to attach to the log. + let extra: [String : Any]? + + let logContext: ParraLogContext +} diff --git a/Parra/Sessions/Logger/Types/ParraLogMarker.swift b/Parra/Sessions/Logger/Types/ParraLogMarker.swift new file mode 100644 index 000000000..01811789d --- /dev/null +++ b/Parra/Sessions/Logger/Types/ParraLogMarker.swift @@ -0,0 +1,29 @@ +// +// ParraLogMarker.swift +// Parra +// +// Created by Mick MacCallum on 7/6/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +public struct ParraLogMarker { + public let timestamp: Date + public let initialLevel: ParraLogLevel + + internal let initialLogContext: ParraLogContext + internal let message: ParraLazyLogParam + + internal init( + timestamp: Date, + message: ParraLazyLogParam, + initialLevel: ParraLogLevel, + initialLogContext: ParraLogContext + ) { + self.timestamp = timestamp + self.message = message + self.initialLevel = initialLevel + self.initialLogContext = initialLogContext + } +} diff --git a/Parra/Sessions/Logger/Types/ParraLogMarkerMeasurement.swift b/Parra/Sessions/Logger/Types/ParraLogMarkerMeasurement.swift new file mode 100644 index 000000000..1e6853a26 --- /dev/null +++ b/Parra/Sessions/Logger/Types/ParraLogMarkerMeasurement.swift @@ -0,0 +1,18 @@ +// +// ParraLogMarkerMeasurement.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +public struct ParraLogMarkerMeasurement { + /// The time that has passed since the start marker was created. + let timeInterval: TimeInterval + + /// A new marker representing the current point in time. Pass this to + /// a subsequent call to ``Logger/measureTime(since:eventName:format:callSiteContext:_:)`` + let nextMarker: ParraLogMarker +} diff --git a/Parra/Sessions/Logger/Types/ParraLogMeasurementFormat.swift b/Parra/Sessions/Logger/Types/ParraLogMeasurementFormat.swift new file mode 100644 index 000000000..8fe11271c --- /dev/null +++ b/Parra/Sessions/Logger/Types/ParraLogMeasurementFormat.swift @@ -0,0 +1,15 @@ +// +// ParraLogMeasurementFormat.swift +// Parra +// +// Created by Mick MacCallum on 7/6/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +public enum ParraLogMeasurementFormat { + case seconds + case pretty + case custom(DateComponentsFormatter) +} diff --git a/Parra/Sessions/Logger/Types/ParraLogProcessedData+ParraDictionaryConvertible.swift b/Parra/Sessions/Logger/Types/ParraLogProcessedData+ParraDictionaryConvertible.swift new file mode 100644 index 000000000..ac5c9674f --- /dev/null +++ b/Parra/Sessions/Logger/Types/ParraLogProcessedData+ParraDictionaryConvertible.swift @@ -0,0 +1,40 @@ +// +// ParraLogProcessedData+ParraDictionaryConvertible.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +extension ParraLogProcessedData: ParraSanitizedDictionaryConvertible { + var sanitized: ParraSanitizedDictionary { + var params: [String : Any] = [ + "level": level.loggerDescription, + "message": message, + "call_site": [ + "file_id": callSiteContext.fileId, + "function": callSiteContext.function, + "line": callSiteContext.line, + "column": callSiteContext.column + ] as [String : Any] + ] + + if let loggerContext { + params["logger_context"] = loggerContext.sanitized + } + + if let extra, !extra.isEmpty { + params["extra"] = extra + } + + let threadInfoDict = callSiteContext.threadInfo.sanitized.dictionary + if !threadInfoDict.isEmpty { + params["thread"] = threadInfoDict + } + + return ParraSanitizedDictionary(dictionary: params) + } +} + diff --git a/Parra/Sessions/Logger/Types/ParraLogProcessedData.swift b/Parra/Sessions/Logger/Types/ParraLogProcessedData.swift new file mode 100644 index 000000000..42a6a6f3e --- /dev/null +++ b/Parra/Sessions/Logger/Types/ParraLogProcessedData.swift @@ -0,0 +1,162 @@ +// +// ParraLogProcessedData.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal struct ParraLogProcessedData { + internal let level: ParraLogLevel + internal let extra: [String : Any]? + + // Differs from the module/filenames in the context. Those could be from + // where a Logger instance was created. These will be from where the final + // log call was made. + internal let callSiteContext: ParraLoggerCallSiteContext + internal let loggerContext: ParraLoggerContext? + + internal let subsystem: String + internal let category: String + internal let message: String + internal let timestamp: Date + + init(logData: ParraLogData) { + let message: String + let errorWithExtra: ParraErrorWithExtra? + + switch logData.message { + case .string(let messageProvider): + message = messageProvider() + + // If a message string is provided, an extra error may be included as well. + // This field is not present when the message type is error. + if let extraError = logData.extraError { + errorWithExtra = LoggerHelpers.extractMessageAndExtra( + from: extraError + ) + } else { + errorWithExtra = nil + } + case .error(let errorProvider): + let extracted = LoggerHelpers.extractMessageAndExtra( + from: errorProvider() + ) + + message = extracted.message + + errorWithExtra = extracted + } + + let logContext = logData.logContext + let loggerContext = logContext.loggerContext + var callSiteContext = logContext.callSiteContext + + if logData.level.requiresStackTraceCapture { + callSiteContext.threadInfo.demangleCallStack() + } + + self.subsystem = callSiteContext.fileId + self.category = ParraLogProcessedData.createCategory( + logContext: logContext + ) + self.level = logData.level + self.timestamp = logData.timestamp + self.loggerContext = loggerContext + self.message = message + self.callSiteContext = callSiteContext + self.extra = ParraLogProcessedData.mergeAllExtras( + callSiteExtra: logData.extra, + loggerExtra: loggerContext?.extra, + errorWithExtra: errorWithExtra + ) + } + + private static func mergeAllExtras( + callSiteExtra: [String : Any]?, + loggerExtra: [String : Any]?, + errorWithExtra: ParraErrorWithExtra? + ) -> [String : Any]? { + + var combinedExtra = loggerExtra ?? [:] + + if let errorWithExtra { + var errorExtra = errorWithExtra.extra ?? [:] + errorExtra["$message"] = errorWithExtra.message + + combinedExtra["$error"] = errorExtra + } + + if let callSiteExtra { + combinedExtra.merge(callSiteExtra) { $1 } + } + + return combinedExtra + } + + private static func createCategory( + logContext: ParraLogContext + ) -> String { + let callingFunctionName = logContext.callSiteContext.function + + guard let loggerContext = logContext.loggerContext else { + // There is no data other than the call site function. No point in building + // a single element category array and joining back to a string. + return callingFunctionName + } + + var categoryComponents = [String]() + + if let category = loggerContext.category { + categoryComponents.append(category) + } + + if !loggerContext.scopes.isEmpty { + var scopes = loggerContext.scopes + + // If there isn't a last element to pop, mapping the scopes wouldn't + // do anything anyway. + if let last = scopes.popLast() { + // Scoped logger in a function uses the function name as a scope. We need to associate + // both that function scope, and the function that the log was created from in the + // case where they aren't the same. + + switch last { + case .customName: + // The scope wasn't a function. Put it back. + scopes.append(last) + + categoryComponents.append( + contentsOf: scopes.map { $0.name } + ) + + categoryComponents.append(callingFunctionName) + case .function(let rawName): + categoryComponents.append( + contentsOf: scopes.map { $0.name } + ) + + // Logs that occur within a scoped logger that haven't exited + // its scope will have a call site function that matches the + // most recent scope. If this happens, we drop the last scope + // in favor of the call site function formatting. If the logger + // exited the original scope, apply special formating to indicate + // that this occurred. + if rawName == callingFunctionName { + categoryComponents.append(callingFunctionName) + } else { + categoryComponents.append( + "\(last.name) -> \(callingFunctionName)" + ) + } + } + } else { + categoryComponents.append(callingFunctionName) + } + } + + return categoryComponents.joined(separator: "/") + } +} diff --git a/Parra/Sessions/Logger/Types/ParraLogStringConvertible.swift b/Parra/Sessions/Logger/Types/ParraLogStringConvertible.swift new file mode 100644 index 000000000..49456a6d1 --- /dev/null +++ b/Parra/Sessions/Logger/Types/ParraLogStringConvertible.swift @@ -0,0 +1,17 @@ +// +// ParraLogStringConvertible.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +/// An alternative to CustomStringConvertible that serves the same purpose, +/// but is intended to prevent the inadvertent changing of strings sent to the +/// logger over time, since this shouldn't be used outside of the Logger +/// unlike CustomStringConvertible. +internal protocol ParraLogStringConvertible { + var loggerDescription: String { get } +} diff --git a/Parra/Sessions/Logger/Types/ParraLoggerBackend.swift b/Parra/Sessions/Logger/Types/ParraLoggerBackend.swift new file mode 100644 index 000000000..faebf67f7 --- /dev/null +++ b/Parra/Sessions/Logger/Types/ParraLoggerBackend.swift @@ -0,0 +1,17 @@ +// +// ParraLoggerBackend.swift +// Parra +// +// Created by Mick MacCallum on 7/6/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal protocol ParraLoggerBackend { + /// bypassEventCreation params are for case for use within Parra SessionStorage infrastructure, + /// in places where writing console logs can not safely generate events due to recursion risks. + + func log(data: ParraLogData) + func logMultiple(data: [ParraLogData]) +} diff --git a/Parra/Sessions/Logger/Types/ParraLoggerContext+ParraDictionaryConvertible.swift b/Parra/Sessions/Logger/Types/ParraLoggerContext+ParraDictionaryConvertible.swift new file mode 100644 index 000000000..54f02244d --- /dev/null +++ b/Parra/Sessions/Logger/Types/ParraLoggerContext+ParraDictionaryConvertible.swift @@ -0,0 +1,40 @@ +// +// ParraLoggerContext+ParraDictionaryConvertible.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +extension ParraLoggerContext: ParraSanitizedDictionaryConvertible { + internal var sanitized: ParraSanitizedDictionary { + var params: [String : Any] = [ + "module": module, + "file_name": fileName + ] + + if let fiberId { + params["fiber_id"] = fiberId + } + + if let fileExtension { + params["file_extension"] = fileExtension + } + + if let category { + params["category"] = category + } + + if !scopes.isEmpty { + params["scopes"] = scopes.map { $0.name } + } + + if let extra, !extra.isEmpty { + params["extra"] = extra + } + + return ParraSanitizedDictionary(dictionary: params) + } +} diff --git a/Parra/Sessions/Logger/Types/ParraLoggerEnvironment.swift b/Parra/Sessions/Logger/Types/ParraLoggerEnvironment.swift new file mode 100644 index 000000000..5462218eb --- /dev/null +++ b/Parra/Sessions/Logger/Types/ParraLoggerEnvironment.swift @@ -0,0 +1,56 @@ +// +// ParraLoggerEnvironment.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +/// Which environment the Parra Logger is executing in. When in `debug` +/// mode, logs are printed to the console. When in `production` mode, logs +/// are uploaded to Parra with other session data. By default, the `automatic` +/// options is set, indicating that the `DEBUG` compilation condition will be +/// used to determine if the environment is `debug` or `production`. +/// This option is exposed in case you have additional schemes besides DEBUG +/// and RELEASE and want to customize your log output in these cases. +/// If the ``ParraLoggerOptions/Environment-swift.enum/eventDebugLoggingEnabled`` +/// environmental variable is set, events will be written to both the console +/// and the user's session regardless of this configuration. +public enum ParraLoggerEnvironment { + public static let `default` = ParraLoggerEnvironment.automatic + + case debug + case production + + case automatic + + internal static var eventDebugLoggingOverrideEnabled: Bool = { + let envVar = ParraLoggerOptions.Environment.eventDebugLoggingEnabled + + if let rawDebugLoggingEnabled = ProcessInfo.processInfo.environment[envVar], + let debugLoggingEnabledNumber = Int(rawDebugLoggingEnabled), + debugLoggingEnabledNumber > 0 { + + return true + } + + return false + }() + + internal var hasConsoleBehavior: Bool { + switch self { + case .debug: + return true + case .production: + return false + case .automatic: +#if DEBUG + return true +#else + return false +#endif + } + } +} diff --git a/Parra/Sessions/Logger/Types/Threads/ParraLoggerStackSymbols.swift b/Parra/Sessions/Logger/Types/Threads/ParraLoggerStackSymbols.swift new file mode 100644 index 000000000..a92634ab0 --- /dev/null +++ b/Parra/Sessions/Logger/Types/Threads/ParraLoggerStackSymbols.swift @@ -0,0 +1,16 @@ +// +// StackSymbols.swift +// Parra +// +// Created by Mick MacCallum on 9/3/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +@usableFromInline +internal enum ParraLoggerStackSymbols: Codable { + case raw([String]) + case demangled([CallStackFrame]) + case none +} diff --git a/Parra/Sessions/Logger/Types/Threads/ParraLoggerThreadInfo+ParraDictionaryConvertible.swift b/Parra/Sessions/Logger/Types/Threads/ParraLoggerThreadInfo+ParraDictionaryConvertible.swift new file mode 100644 index 000000000..b3cc9f922 --- /dev/null +++ b/Parra/Sessions/Logger/Types/Threads/ParraLoggerThreadInfo+ParraDictionaryConvertible.swift @@ -0,0 +1,41 @@ +// +// ParraLoggerThreadInfo+ParraDictionaryConvertible.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +extension ParraLoggerThreadInfo: ParraSanitizedDictionaryConvertible { + internal var sanitized: ParraSanitizedDictionary { + var params: [String : Any] = [ + "id": id, + "queue_name": queueName, + "stack_size": stackSize, + "priority": priority, + "qos": qualityOfService.loggerDescription + ] + + if let threadName { + params["name"] = threadName + } + + if let threadNumber { + params["number"] = threadNumber + } + + switch callStackSymbols { + case .raw(let array): + params["stack_frames"] = array + case .demangled(let array): + params["stack_frames"] = array + case .none: + // No symbols, don't set the key + break + } + + return ParraSanitizedDictionary(dictionary: params) + } +} diff --git a/Parra/Sessions/Logger/Types/Threads/ParraLoggerThreadInfo.swift b/Parra/Sessions/Logger/Types/Threads/ParraLoggerThreadInfo.swift new file mode 100644 index 000000000..8dfee1b12 --- /dev/null +++ b/Parra/Sessions/Logger/Types/Threads/ParraLoggerThreadInfo.swift @@ -0,0 +1,57 @@ +// +// ParraLoggerThreadInfo.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +@usableFromInline +internal struct ParraLoggerThreadInfo: Codable { + internal let id: Int + internal let queueName: String + internal let stackSize: Int + internal let priority: Double // 0.0...1.0 + internal let qualityOfService: QualityOfService + internal let threadName: String? + internal let threadNumber: UInt8? + internal private(set) var callStackSymbols: ParraLoggerStackSymbols + + @usableFromInline + internal init( + thread: Thread, + callStackSymbols: ParraLoggerStackSymbols = .none + ) { + self.id = thread.threadId + self.queueName = thread.queueName + self.stackSize = thread.stackSize + self.priority = thread.threadPriority + self.qualityOfService = thread.qualityOfService + self.callStackSymbols = callStackSymbols + + if let (threadName, threadNumber) = thread.threadNameAndNumber { + self.threadName = threadName + self.threadNumber = threadNumber + } else { + self.threadName = nil + self.threadNumber = nil + } + } + + /// Demangles the call stack symbols and stores them in place of the raw symbols, if symbols + /// exist and they haven't already been demangled. + mutating internal func demangleCallStack() { + switch callStackSymbols { + case .raw(let frames): + let demangledFrames = CallStackParser.parse( + frames: frames + ) + + self.callStackSymbols = .demangled(demangledFrames) + case .demangled, .none: + break + } + } +} diff --git a/Parra/Sessions/Logger/Types/Threads/QualityOfService+Codable.swift b/Parra/Sessions/Logger/Types/Threads/QualityOfService+Codable.swift new file mode 100644 index 000000000..22b2f343e --- /dev/null +++ b/Parra/Sessions/Logger/Types/Threads/QualityOfService+Codable.swift @@ -0,0 +1,11 @@ +// +// QualityOfService+Codable.swift +// Parra +// +// Created by Mick MacCallum on 9/3/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +extension QualityOfService: Codable {} diff --git a/Parra/Sessions/Manager/ParraSessionEventTarget.swift b/Parra/Sessions/Manager/ParraSessionEventTarget.swift new file mode 100644 index 000000000..e9875c757 --- /dev/null +++ b/Parra/Sessions/Manager/ParraSessionEventTarget.swift @@ -0,0 +1,27 @@ +// +// ParraSessionEventTarget.swift +// Parra +// +// Created by Mick MacCallum on 8/30/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal enum ParraSessionEventTarget { + /// Write to all event targets (console and session currently) with the exception of + /// log events that explicitly bypass being written to sessions. + case all + + /// The indended default behavior. Scheme/user config dependent. + case automatic + + /// Only write events to the console. + case console + + /// Only write events to the user's session. + case session + + /// Events won't be written anywhere. + case none +} diff --git a/Parra/Sessions/Manager/ParraSessionManager+LogFormatters.swift b/Parra/Sessions/Manager/ParraSessionManager+LogFormatters.swift new file mode 100644 index 000000000..3fed00efc --- /dev/null +++ b/Parra/Sessions/Manager/ParraSessionManager+LogFormatters.swift @@ -0,0 +1,155 @@ +// +// ParraSessionManager+LogFormatters.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +fileprivate let logger = Logger(bypassEventCreation: true, category: "LogFormatters") + +extension ParraSessionManager { + internal struct Constant { + static let maxValueLength: Int = 80 + } + + internal func format( + timestamp: Date, + with style: ParraLoggerTimestampStyle + ) -> String { + let formatted: String + switch style { + case .custom(let formatter): + formatted = formatter.string(from: timestamp) + case .epoch: + formatted = "\(timestamp.timeIntervalSince1970)" + case .iso8601: + formatted = Parra.InternalConstants.Formatters.iso8601Formatter.string( + from: timestamp + ) + case .rfc3339: + formatted = Parra.InternalConstants.Formatters.rfc3339DateFormatter.string( + from: timestamp + ) + } + + return formatted + } + + internal func format( + level: ParraLogLevel, + with style: ParraLoggerLevelStyle + ) -> String { + switch style { + case .symbol: + return level.symbol + case .word: + return level.name + case .both: + return "\(level.symbol) \(level.name)" + } + } + + internal func format( + callSite: ParraLoggerCallSiteContext, + with style: ParraLoggerCallSiteStyleOptions + ) -> String { + var components = [String]() + + if style.contains(.function) { + components.append(callSite.function) + } + + if style.contains(.line) { + components.append(String(callSite.line)) + } + + if style.contains(.column) { + components.append(String(callSite.column)) + } + + let joined = components.joined(separator: ":") + + if style.contains(.thread) { + return "[\(callSite.threadInfo.queueName)] \(joined)" + } + + return joined + } + + internal func format( + extra: [String : Any]?, + with style: ParraLoggerExtraStyle + ) -> String? { + guard let extra, !extra.isEmpty else { + return nil + } + + switch style { + case .raw: + return extra.description + case .condensed: + return recursivelyTruncateValues( + of: extra, + to: Constant.maxValueLength + ).description + case .pretty: + do { + let data = try JSONEncoder.parraPrettyConsoleEncoder.encode(AnyCodable(extra)) + + // NSString is necessary to prevent additional escapes of quotations from being + // added in the Xcode console. + return NSString(data: data, encoding: NSUTF8StringEncoding) as String? + } catch let error { + logger.error("Error formatting extra dictionary with style 'pretty'", error) + + return nil + } + } + } + + internal func format(callstackSymbols: ParraLoggerStackSymbols) -> String? { + switch callstackSymbols { + case .none: + return nil + case .raw(let frames): + return frames.joined(separator: "\n") + case .demangled(let frames): + return frames.map({ frame in + return "\(frame.frameNumber)\t\(frame.binaryName)\t\(frame.address)\t\(frame.symbol) + \(frame.byteOffset)" + }).joined(separator: "\n") + } + } + + internal func paddedIfPresent( + string: String?, + leftPad: String, + rightPad: String + ) -> String? { + guard let string else { + return nil + } + + return leftPad + string + rightPad + } + + private func recursivelyTruncateValues( + of dictionary: [String : Any], + to maxLength: Int + ) -> [String : Any] { + return dictionary.mapValues { value in + if let stringValue = value as? String { + return "\(stringValue.prefix(maxLength))..." + } else if let dictValue = value as? [String : Any] { + return recursivelyTruncateValues( + of: dictValue, + to: maxLength + ) + } else { + return value + } + } + } +} diff --git a/Parra/Sessions/Manager/ParraSessionManager+LogProcessors.swift b/Parra/Sessions/Manager/ParraSessionManager+LogProcessors.swift new file mode 100644 index 000000000..da63e0ed0 --- /dev/null +++ b/Parra/Sessions/Manager/ParraSessionManager+LogProcessors.swift @@ -0,0 +1,186 @@ +// +// ParraSessionManager+LogProcessors.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation +import os + +// TODO: Markers are wrapper around OSSignPoster https://developer.apple.com/documentation/os/logging/recording_performance_data + +// TODO: Automatically create os activity when creating a logger instance +// auto apply events when logs happen. Auto handle child activities +// when child loggers are created. +// https://developer.apple.com/documentation/os/logging/collecting_log_messages_in_activities + +extension ParraSessionManager { + /// -requirement: Must only ever be invoked from ``ParraSessionManager/eventQueue`` + internal func writeEventToConsoleSync( + wrappedEvent: ParraWrappedEvent, + with consoleFormatOptions: [ParraLoggerConsoleFormatOption], + callSiteContext: ParraLoggerCallSiteContext + ) { + if case let .logEvent(event) = wrappedEvent { + writeLogEventToConsoleSync( + processedLogData: event.logData, + with: consoleFormatOptions + ) + + return + } + + let (name, combinedExtra) = ParraSessionEvent.normalizedEventData( + from: wrappedEvent + ) + + writeSessionEventToConsole( + named: name, + extra: combinedExtra, + isInternalEvent: wrappedEvent.isInternal, + with: consoleFormatOptions, + callSiteContext: callSiteContext + ) + } + + /// -requirement: Must only ever be invoked from ``ParraSessionManager/eventQueue`` + internal func writeLogEventToConsoleSync( + processedLogData: ParraLogProcessedData, + with consoleFormatOptions: [ParraLoggerConsoleFormatOption] + ) { + let messageSegments = createMessageSegments( + for: consoleFormatOptions, + message: processedLogData.message, + timestamp: processedLogData.timestamp, + level: processedLogData.level, + extra: processedLogData.extra, + callSiteContext: processedLogData.callSiteContext + ) + + var message = messageSegments.joined().trimmingCharacters( + in: .whitespacesAndNewlines + ) + + if processedLogData.level.requiresStackTraceCapture { + let callstackSymbols = processedLogData.callSiteContext.threadInfo.callStackSymbols + + if let formattedStackTrace = format( + callstackSymbols: callstackSymbols + ) { + message.append("\n") + message.append(formattedStackTrace) + } + } + + let systemLogger = os.Logger( + subsystem: processedLogData.subsystem, + category: processedLogData.category + ) + + systemLogger.log( + level: processedLogData.level.osLogType, + "\(message)" + ) + } + + /// Events that are specifically not log events. Anything that happened during the session. User actions, + /// async system events, etc, but not logs. + private func writeSessionEventToConsole( + named name: String, + extra: [String : Any], + isInternalEvent: Bool, + with consoleFormatOptions: [ParraLoggerConsoleFormatOption], + callSiteContext: ParraLoggerCallSiteContext + ) { + let messageSegments = createMessageSegments( + for: consoleFormatOptions, + message: name, + timestamp: .now, + level: nil, + // Internal events shouldn't reveal extra data in console logs. + extra: isInternalEvent ? nil : extra, + callSiteContext: callSiteContext + ) + + let subsystem = "Parra Event" + let category = isInternalEvent ? "Internal" : "User Generated" + let prefix = "✨ [\(subsystem)][\(category)]" + let message = messageSegments.joined().trimmingCharacters( + in: .whitespacesAndNewlines + ) + + let systemLogger = os.Logger( + subsystem: subsystem, + category: category + ) + + systemLogger.log( + level: .default, + """ + \(prefix)\(message) + """ + ) + } + + private func createMessageSegments( + for consoleFormatOptions: [ParraLoggerConsoleFormatOption], + message: String, + timestamp: Date, + level: ParraLogLevel?, + extra: [String : Any]?, + callSiteContext: ParraLoggerCallSiteContext + ) -> [String] { + return consoleFormatOptions.compactMap { option in + switch option { + case .printMessage(let leftPad, let rightPad): + return paddedIfPresent( + string: message, + leftPad: leftPad, + rightPad: rightPad + ) + case .printTimestamps(let style, let leftPad, let rightPad): + return paddedIfPresent( + string: format( + timestamp: timestamp, + with: style + ), + leftPad: leftPad, + rightPad: rightPad + ) + case .printLevel(let style, let leftPad, let rightPad): + if let level { + return paddedIfPresent( + string: format( + level: level, + with: style + ), + leftPad: leftPad, + rightPad: rightPad + ) + } + + return nil + case .printCallSite(let options, let leftPad, let rightPad): + return paddedIfPresent( + string: format( + callSite: callSiteContext, + with: options + ), + leftPad: leftPad, + rightPad: rightPad + ) + case .printExtras(let style, let leftPad, let rightPad): + return paddedIfPresent( + string: format( + extra: extra, + with: style + ), + leftPad: leftPad, + rightPad: rightPad + ) + } + } + } +} diff --git a/Parra/Sessions/Manager/ParraSessionManager.swift b/Parra/Sessions/Manager/ParraSessionManager.swift new file mode 100644 index 000000000..c5575e923 --- /dev/null +++ b/Parra/Sessions/Manager/ParraSessionManager.swift @@ -0,0 +1,387 @@ +// +// ParraSessionManager.swift +// Parra +// +// Created by Mick MacCallum on 11/19/22. +// Copyright © 2022 Parra, Inc. All rights reserved. +// + +import Foundation +import UIKit + +// NOTE: Any logs used in here cause recursion in production + +fileprivate let logger = Logger(bypassEventCreation: true, category: "Session manager") + +// Needs +// 1. Logging events without waiting. +// 2. Thread safe state to know if there is pending sync data +// 3. Ability to wait on writes to finish when logging if desired +// 4. Open file handle when session starts +// 5. Log events to memory as soon as their written +// 6. Events are written to disk in batches +// 7. Certain high priority events trigger a write immediately + +/// Handles receiving logs, session properties and events and coordinates passing these +/// to other services responsible for underlying storage. +/// +/// Some important notes working with this class: +/// 1. For any method with the `Sync` suffix, you should always assume that it is a hard +/// requirement for it to be called on a specific queue. Usually ``eventQueue``. +@usableFromInline +internal class ParraSessionManager { + private let dataManager: ParraDataManager + private let networkManager: ParraNetworkManager + private var loggerOptions: ParraLoggerOptions + + fileprivate let eventQueue: DispatchQueue + + private var sessionStorage: SessionStorage { + return dataManager.sessionStorage + } + + internal init( + dataManager: ParraDataManager, + networkManager: ParraNetworkManager, + loggerOptions: ParraLoggerOptions + ) { + self.dataManager = dataManager + self.networkManager = networkManager + self.loggerOptions = loggerOptions + + // Set this in init after assigning the loggerOptions to ensure reads from the event queue couldn't + // possibly start happening until after the initial write of these options is complete. + eventQueue = DispatchQueue( + label: "com.parra.sessions.event-queue", + qos: .utility + ) + } + + /// The config state will be exclusively accessed on a serial queue that will use + /// barriers. Since Parra's config state uses an actor, it is illegal to attempt + /// to access its value synchronously. So the safest thing to do is pass a copy + /// to this class every time it updates, and process the change on the event queue. + internal func updateLoggerOptions( + loggerOptions: ParraLoggerOptions + ) { + eventQueue.async { + self.loggerOptions = loggerOptions + } + } + + internal func initializeSessions() async { + await sessionStorage.initializeSessions() + } + + internal func hasDataToSync(since date: Date?) async -> Bool { + // Checks made in order by least resources required to check them: + // 1. The current session has been updated + // 2. There are new events + // 3. There are previous sessions + + return await logger.withScope { logger in + if await sessionStorage.hasSessionUpdates(since: date) { + logger.trace("has updates to current session") + + return true + } + + if await sessionStorage.hasNewEvents() { + logger.trace("has new events on current session") + + return true + } + + // If there are sessions other than the one in progress, they should be synced. + if await sessionStorage.hasCompletedSessions() { + logger.trace("has completed sessions") + + return true + } + + logger.trace("no updates require sync") + + return false + } + } + + func synchronizeData() async throws -> ParraSessionsResponse? { + return try await logger.withScope { logger in + let currentSession = try await sessionStorage.getCurrentSession() + let sessionIterator = try await sessionStorage.getAllSessions() + + var removableSessionIds = Set() + var erroredSessionIds = Set() + + let markSessionForDirectoryAsErrored = { (directory: URL) -> Void in + let ext = ParraSession.Constant.packageExtension + guard directory.pathExtension == ext else { + return + } + + let sessionId = directory.deletingPathExtension().lastPathComponent + + // A session directory being prefixed with an underscore indicates that we + // have already made an attempt to synchronize it, which has failed. + // If the session has failed to synchronize again, we want to cut our + // losses and delete it, so it doesn't cause the sync manager to think + // there are more sessions to sync. + + if sessionId.hasPrefix(ParraSession.Constant.erroredSessionPrefix) { + logger.trace("Marking previously errored session for removal: \(sessionId)") + + removableSessionIds.insert(sessionId) + } else { + logger.trace("Marking session as errored: \(sessionId)") + + erroredSessionIds.insert(sessionId) + } + } + + // It's possible that multiple sessions that are uploaded could receive a response indicating that polling + // should occur. If this happens, we'll honor the most recent of these. + var sessionResponse: ParraSessionsResponse? + + for await nextSession in sessionIterator { + switch nextSession { + case .success(let sessionDirectory, let sessionUpload): + logger.trace("Session upload iterator produced session", [ + "sessionId": String(describing: sessionUpload.session.sessionId) + ]) + + // Reference the session ID from the path instead of from reading the session + // object, since this one will include the deletion marker. + let sessionId = sessionDirectory.deletingPathExtension().lastPathComponent + + if currentSession.sessionId == sessionId { + // Sets a marker on the current session to indicate the offset of the file handle that stores events + // just before the sync starts. This is necessary to make sure that any new events that roll in + // while the sync is in progress aren't deleted as part of post-sync cleanup. + await sessionStorage.recordSyncBegan() + } + + logger.debug("Uploading session: \(sessionId)") + + let response = try await networkManager.submitSession(sessionUpload) + + switch response.result { + case .success(let payload): + logger.debug("Successfully uploaded session: \(sessionId)") + + // Don't override the session response unless it's another one with shouldPoll enabled. + if payload.shouldPoll { + sessionResponse = payload + } + + removableSessionIds.insert(sessionId) + case .failure(let error): + logger.error("Failed to upload session: \(sessionId)", error) + + markSessionForDirectoryAsErrored(sessionDirectory) + + // If any of the sessions fail to upload afty rerying, fail the entire operation + // returning the sessions that have been completed so far. + if response.attributes.contains(.exceededRetryLimit) { + logger.debug( + "Network retry limited exceeded. Will not attempt to sync additional sessions." + ) + break + } + } + case .error(let sessionDirectory, let error): + markSessionForDirectoryAsErrored(sessionDirectory) + + logger.error("Error synchronizing session", error) + } + } + + try await sessionStorage.deleteSessions( + for: removableSessionIds, + erroredSessions: erroredSessionIds + ) + + return sessionResponse + } + } + + /// Logs the supplied event on the user's session. + /// Do not interact with this method directly. Logging events should be done through the + /// Parra.logEvent helpers. + @usableFromInline + internal func writeEvent( + wrappedEvent: ParraWrappedEvent, + callSiteContext: ParraLoggerCallSiteContext + ) { + eventQueue.async { [self] in + writeEventSync( + wrappedEvent: wrappedEvent, + target: .automatic, + callSiteContext: callSiteContext + ) + } + } + + internal func writeEventSync( + wrappedEvent: ParraWrappedEvent, + target: ParraSessionEventTarget, + callSiteContext: ParraLoggerCallSiteContext + ) { + let environment = loggerOptions.environment + // When normal behavior isn't bypassed, debug behavior is to send logs to the console. + // Production behavior is to write events. + + let writeToConsole = { [self] in + writeEventToConsoleSync( + wrappedEvent: wrappedEvent, + with: loggerOptions.consoleFormatOptions, + callSiteContext: callSiteContext + ) + } + + let writeToSession = { [self] in + writeEventToSessionSync( + wrappedEvent: wrappedEvent, + callSiteContext: callSiteContext + ) + } + + switch target { + case .all: + writeToConsole() + writeToSession() + case .automatic: + if environment.hasConsoleBehavior { + writeToConsole() + } else { + writeToSession() +#if DEBUG + // If we're running tests, honor the configured behavior, but also write + // to console, if we weren't already going to. + if NSClassFromString("XCTestCase") != nil { + writeToConsole() + } +#endif + } + case .console: + writeToConsole() + case .session: + writeToSession() + case .none: + // The event is explicitly being skipped. + break + } + } + + private func writeEventToSessionSync( + wrappedEvent: ParraWrappedEvent, + callSiteContext: ParraLoggerCallSiteContext + ) { + let (event, context) = ParraSessionEvent.sessionEventFromEventWrapper( + wrappedEvent: wrappedEvent, + callSiteContext: callSiteContext + ) + + sessionStorage.writeEvent( + event: event, + context: context + ) + } + + internal func setUserProperty( + _ value: Any?, + forKey key: String + ) { + sessionStorage.writeUserPropertyUpdate( + key: key, + value: value + ) + } + + internal func endSession() async { + await sessionStorage.endSession() + } +} + +// MARK: ParraLoggerBackend +extension ParraSessionManager: ParraLoggerBackend { + internal func log( + data: ParraLogData + ) { + eventQueue.async { [self] in + logEventReceivedSync( + logData: data + ) + } + } + + internal func logMultiple( + data: [ParraLogData] + ) { + eventQueue.async { [self] in + for logData in data { + logEventReceivedSync( + logData: logData + ) + } + } + } + + /// Any newly created log is required to pass through this method in order to ensure consistent + /// filtering and processing. This method should always be called from the eventQueue. + /// - Parameters: + /// - bypassEventCreation: This flag should be used for cases where we need to write logs + /// that can not trigger the creation of log events, and should instead just be written directly to + /// the console. This is primarily for places where writing events for logs would create recursion, + /// like logs generated by the services that store log events, for example. For now we will have a + /// blind spot in these places until a better solution is implemented. + private func logEventReceivedSync( + logData: ParraLogData + ) { + guard logData.level >= loggerOptions.minimumLogLevel else { + return + } + + // At this point, the autoclosures passed to the logger functions are finally invoked. + let processedLogData = ParraLogProcessedData( + logData: logData + ) + + let wrappedEvent = ParraWrappedEvent.logEvent( + event: ParraLogEvent( + logData: processedLogData + ) + ) + + // 1. The flag to bypass event creation takes precedence, since this is used in cases like logs + // within the logging infrastructure that could cause recursion in error cases. If it is set + // we send logs to the console instead of the session in DEBUG mode. Since the automatic behavior + // in RELEASE is to write to sessions, we skip writing entirely in this case. + // TODO: This will eventually need to be addressed to help catch errors in this part of our code. + // + // 2. The next check is for the event debug logging override flag, which is set by any environmental + // variable. This is used to allow us to force writing to both sessions and the consoles during + // development for testing purposes. + // + // 3. Fall back on the default behavior that takes scheme and user preferences into consideration. + + let target: ParraSessionEventTarget + if logData.logContext.bypassEventCreation { +#if DEBUG + target = .console +#else + target = .none +#endif + } else if ParraLoggerEnvironment.eventDebugLoggingOverrideEnabled { + target = .all + } else { + target = .automatic + } + + writeEventSync( + wrappedEvent: wrappedEvent, + target: target, + // Special case. Call site context is that of the origin of the log. + callSiteContext: logData.logContext.callSiteContext + ) + } +} diff --git a/Parra/Sessions/Types/Events/ParraEvent.swift b/Parra/Sessions/Types/Events/ParraEvent.swift new file mode 100644 index 000000000..612a420ce --- /dev/null +++ b/Parra/Sessions/Types/Events/ParraEvent.swift @@ -0,0 +1,40 @@ +// +// ParraEvent.swift +// Parra +// +// Created by Mick MacCallum on 6/25/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + + +public protocol ParraEvent { + /// A unique name for the event. Event names should be all lowercase and in_snake_case. + var name: String { get } +} + +public protocol ParraDataEvent: ParraEvent { + var extra: [String : Any] { get } +} + +public struct ParraBasicEvent: ParraEvent { + public var name: String + + public init(name: String) { + self.name = name + } +} + +public struct ParraBasicDataEvent: ParraDataEvent { + public var name: String + public var extra: [String : Any] + + public init( + name: String, + extra: [String : Any] + ) { + self.name = name + self.extra = extra + } +} diff --git a/Parra/Sessions/Types/Events/ParraInternalEvent.swift b/Parra/Sessions/Types/Events/ParraInternalEvent.swift new file mode 100644 index 000000000..db7bc7459 --- /dev/null +++ b/Parra/Sessions/Types/Events/ParraInternalEvent.swift @@ -0,0 +1,121 @@ +// +// ParraInternalEvent.swift +// Parra +// +// Created by Mick MacCallum on 6/25/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation +import UIKit + +/// Events that should only be generated from within the Parra SDK. Events that can be generated +/// either in or outside of the SDK, should use the `ParraEvent` type. +@usableFromInline +internal enum ParraInternalEvent: ParraDataEvent { + case appStateChanged + case batteryLevelChanged + case batteryStateChanged + case diskSpaceLow + case httpRequest(request: URLRequest, response: HTTPURLResponse) + case keyboardDidHide + case keyboardDidShow + case memoryWarning + case orientationChanged + case powerStateChanged + case screenshotTaken + case significantTimeChange + case thermalStateChanged + + // MUST all be snake_case. Internal events are allowed to skip automatic conversion. + @usableFromInline + var name: String { + switch self { + case .appStateChanged: + return "app_state_changed" + case .batteryLevelChanged: + return "battery_level_changed" + case .batteryStateChanged: + return "battery_state_changed" + case .diskSpaceLow: + return "disk_space_low" + case .httpRequest: + return "http_request" + case .keyboardDidHide: + return "keyboard_did_hide" + case .keyboardDidShow: + return "keyboard_did_show" + case .memoryWarning: + return "memory_warning" + case .orientationChanged: + return "orientation_changed" + case .powerStateChanged: + return "power_state_changed" + case .screenshotTaken: + return "screenshot_taken" + case .significantTimeChange: + return "significant_time_change" + case .thermalStateChanged: + return "thermal_state_changed" + } + } + + @usableFromInline + var extra: [String : Any] { + switch self { + case .httpRequest(let request, let response): + return [ + "request": request.sanitized.dictionary, + "response": response.sanitized.dictionary, + ] + default: + return [:] + } + } + + @usableFromInline + var displayName: String { + switch self { + case .appStateChanged: + return "app state changed to: \(UIApplication.shared.applicationState.loggerDescription)" + case .batteryLevelChanged: + return "battery level changed to: \(UIDevice.current.batteryLevel.formatted(.percent))" + case .batteryStateChanged: + return "battery state changed to: \(UIDevice.current.batteryState.loggerDescription)" + case .diskSpaceLow: + return "disk space is low" + case .httpRequest(let request, _): + guard let method = request.httpMethod, + let url = request.url, + let scheme = url.scheme else { + + return "HTTP request" + } + + let path = url.pathComponents.dropFirst(1).joined(separator: "/") + var endpoint = "" + if let host = url.host(percentEncoded: false) { + endpoint = "\(scheme)://\(host)/" + } + endpoint.append(path) + + return "\(method.uppercased()) \(endpoint)" + case .keyboardDidHide: + return "keyboard did hide" + case .keyboardDidShow: + return "keyboard did show" + case .memoryWarning: + return "received memory warning" + case .orientationChanged: + return "orientation changed to: \(UIDevice.current.orientation.loggerDescription)" + case .powerStateChanged: + return "power state changed to: \(ProcessInfo.processInfo.powerState.loggerDescription)" + case .screenshotTaken: + return "screenshot taken" + case .significantTimeChange: + return "significant time change" + case .thermalStateChanged: + return "thermal state changed to: \(ProcessInfo.processInfo.thermalState.loggerDescription)" + } + } +} diff --git a/Parra/Sessions/Types/Events/ParraLogEvent.swift b/Parra/Sessions/Types/Events/ParraLogEvent.swift new file mode 100644 index 000000000..c0145adee --- /dev/null +++ b/Parra/Sessions/Types/Events/ParraLogEvent.swift @@ -0,0 +1,26 @@ +// +// ParraLogEvent.swift +// Parra +// +// Created by Mick MacCallum on 9/10/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +@usableFromInline +internal struct ParraLogEvent: ParraDataEvent { + @usableFromInline + var extra: [String : Any] { + return logData.sanitized.dictionary + } + + @usableFromInline + let name: String = "log" + + let logData: ParraLogProcessedData + + internal init(logData: ParraLogProcessedData) { + self.logData = logData + } +} diff --git a/Parra/Sessions/Types/Events/ParraSessionEvent/ParraSessionEvent.swift b/Parra/Sessions/Types/Events/ParraSessionEvent/ParraSessionEvent.swift new file mode 100644 index 000000000..5431ad889 --- /dev/null +++ b/Parra/Sessions/Types/Events/ParraSessionEvent/ParraSessionEvent.swift @@ -0,0 +1,125 @@ +// +// ParraSessionEvent.swift +// Parra +// +// Created by Mick MacCallum on 7/22/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +/// Contains ``ParraEvent`` data suitable for storage within a session. +internal struct ParraSessionEvent: Codable { + internal let createdAt: Date + internal let name: String + + // Stop thinking this can be renamed. There is a server-side validation on this field name. + internal let metadata: AnyCodable + + internal init( + createdAt: Date, + name: String, + metadata: AnyCodable + ) { + self.createdAt = createdAt + self.name = name + self.metadata = metadata + } + + internal static func normalizedEventData( + from wrappedEvent: ParraWrappedEvent + ) -> (name: String, extra: [String : Any]) { + let name: String + let combinedExtra: [String : Any] + + switch wrappedEvent { + case .event(let event, let extra): + name = event.name + + if let extra { + combinedExtra = extra + } else { + combinedExtra = [:] + } + case .dataEvent(let event, let extra): + name = event.name + + if let extra { + combinedExtra = event.extra.merging(extra) { $1 } + } else { + combinedExtra = event.extra + } + case .internalEvent(let event, let extra): + name = event.displayName + + var merged = if let extra { + event.extra.merging(extra) { $1 } + } else { + event.extra + } + + merged["name"] = event.name + + combinedExtra = merged + case .logEvent(let event): + name = event.name + combinedExtra = event.extra + } + + return (name, combinedExtra) + } + + internal static func normalizedEventContextData( + from wrappedEvent: ParraWrappedEvent + ) -> (isClientGenerated: Bool, syncPriority: ParraSessionEventSyncPriority) { + + guard case let .logEvent(event) = wrappedEvent else { + return (false, .low) + } + + // It's possible that this will need to be updated to be more clever in the future + // but for now, we consider an event to be generated from within Parra if the + // current module at the call site is the name of the Parra module. + let isClientGenerated = !LoggerHelpers.isFileIdInternal( + fileId: event.logData.callSiteContext.fileId + ) + + let level = event.logData.level + + let syncPriority: ParraSessionEventSyncPriority = if case .error = level { + .high + } else if case .fatal = level { + .critical + } else { + .low + } + + return (isClientGenerated, syncPriority) + } + + internal static func sessionEventFromEventWrapper( + wrappedEvent: ParraWrappedEvent, + callSiteContext: ParraLoggerCallSiteContext + ) -> ( + event: ParraSessionEvent, + context: ParraSessionEventContext + ) { + let (name, combinedExtra) = normalizedEventData(from: wrappedEvent) + let (isClientGenerated, syncPriority) = normalizedEventContextData(from: wrappedEvent) + + return ( + event: ParraSessionEvent( + createdAt: .now, + name: StringManipulators.snakeCaseify( + text: name + ), + metadata: AnyCodable.init(combinedExtra) + ), + context: ParraSessionEventContext( + isClientGenerated: isClientGenerated, + syncPriority: syncPriority + ) + ) + } +} + diff --git a/Parra/Sessions/Types/Events/ParraSessionEvent/ParraSessionEventContext.swift b/Parra/Sessions/Types/Events/ParraSessionEvent/ParraSessionEventContext.swift new file mode 100644 index 000000000..a13dc433e --- /dev/null +++ b/Parra/Sessions/Types/Events/ParraSessionEvent/ParraSessionEventContext.swift @@ -0,0 +1,19 @@ +// +// ParraSessionEventContext.swift +// Parra +// +// Created by Mick MacCallum on 9/10/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal struct ParraSessionEventContext { + /// The event was generated from outside of the Parra SDK. + internal let isClientGenerated: Bool + + /// An indication to the session storage module about the priority of this event + /// with regards to syncs. Used to make sure that low priority events that happen + /// during syncs are not used to trigger new syncs. + internal let syncPriority: ParraSessionEventSyncPriority +} diff --git a/Parra/Sessions/Types/Events/ParraSessionEvent/ParraSessionEventSyncPriority.swift b/Parra/Sessions/Types/Events/ParraSessionEvent/ParraSessionEventSyncPriority.swift new file mode 100644 index 000000000..65eeebb59 --- /dev/null +++ b/Parra/Sessions/Types/Events/ParraSessionEvent/ParraSessionEventSyncPriority.swift @@ -0,0 +1,15 @@ +// +// ParraSessionEventSyncPriority.swift +// Parra +// +// Created by Mick MacCallum on 9/10/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal enum ParraSessionEventSyncPriority: Codable { + case low + case high + case critical +} diff --git a/Parra/Sessions/Types/Events/ParraStandardEvent.swift b/Parra/Sessions/Types/Events/ParraStandardEvent.swift new file mode 100644 index 000000000..f119f1507 --- /dev/null +++ b/Parra/Sessions/Types/Events/ParraStandardEvent.swift @@ -0,0 +1,94 @@ +// +// ParraStandardEvent.swift +// Parra +// +// Created by Mick MacCallum on 6/25/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +public enum ParraStandardEvent: ParraDataEvent { + case action(source: String) + case close(screen: String) + case custom(name: String, extra: [String : Any]) + case open(screen: String) + case purchase(product: String) + case start(span: String) + case stop(span: String) + case submit(form: String) + case tap(element: String) + case view(element: String) + + public var name: String { + switch self { + case .action(let source): + return "action:\(source)" + case .close(let screen): + return "close:\(screen)" + case .custom(let name, _): + return "custom:\(name)" + case .open(let screen): + return "open:\(screen)" + case .purchase(let product): + return "purchase:\(product)" + case .start(let span): + return "start:\(span)" + case .stop(let span): + return "stop:\(span)" + case .submit(let form): + return "submit:\(form)" + case .tap(let element): + return "tap:\(element)" + case .view(let element): + return "view:\(element)" + } + } + + public var extra: [String : Any] { + switch self { + case .custom(_, let extra): + return extra + default: + return [:] + } + } + + public init(name: String, extra: [String : Any]) { + let components = name.split(separator: ":") + if components.count != 2 { + self = .custom(name: name, extra: extra) + return + } + + let nameKey = String(components[0]) + let value = String(components[1]) + switch nameKey { + case "action": + self = .action(source: value) + case "close": + self = .close(screen: value) + case "custom": + self = .custom(name: value, extra: extra) + case "open": + self = .open(screen: value) + case "purchase": + self = .purchase(product: value) + case "start": + self = .start(span: value) + case "stop": + self = .stop(span: value) + case "submit": + self = .submit(form: value) + case "tap": + self = .tap(element: value) + case "view": + self = .view(element: value) + default: + self = .custom( + name: value, + extra: extra + ) + } + } +} diff --git a/Parra/Sessions/Types/Events/ParraWrappedEvent.swift b/Parra/Sessions/Types/Events/ParraWrappedEvent.swift new file mode 100644 index 000000000..0ed00b9fb --- /dev/null +++ b/Parra/Sessions/Types/Events/ParraWrappedEvent.swift @@ -0,0 +1,40 @@ +// +// ParraWrappedEvent.swift +// Parra +// +// Created by Mick MacCallum on 7/22/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +@usableFromInline +internal enum ParraWrappedEvent { + case event( + event: ParraEvent, + extra: [String : Any]? = nil + ) + + case dataEvent( + event: ParraDataEvent, + extra: [String : Any]? = nil + ) + + case internalEvent( + event: ParraInternalEvent, + extra: [String : Any]? = nil + ) + + case logEvent( + event: ParraLogEvent + ) + + var isInternal: Bool { + switch self { + case .internalEvent: + return true + default: + return false + } + } +} diff --git a/Parra/Sessions/Types/ParraDataSanitizer.swift b/Parra/Sessions/Types/ParraDataSanitizer.swift new file mode 100644 index 000000000..8d57fce73 --- /dev/null +++ b/Parra/Sessions/Types/ParraDataSanitizer.swift @@ -0,0 +1,36 @@ +// +// ParraDataSanitizer.swift +// Parra +// +// Created by Mick MacCallum on 9/14/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal struct ParraDataSanitizer { + private struct Constant { + static let naughtyHeaders = Set( + [ + "AUTHORIZATION", + "COOKIE", + "FORWARDED", + "PROXY-AUTHORIZATION", + "REMOTE-ADDR", + "SET-COOKIE", + "X-API-KEY", + "X-CSRF-TOKEN", + "X-CSRFTOKEN", + "X-FORWARDED-FOR", + "X-REAL-IP", + "X-XSRF-TOKEN" + ] + ) + } + + static func sanitize(httpHeaders: [String : String]) -> [String : String] { + return httpHeaders.filter { (name, _) in + return !Constant.naughtyHeaders.contains(name.uppercased()) + } + } +} diff --git a/Parra/Sessions/Types/ParraDiskUsage.swift b/Parra/Sessions/Types/ParraDiskUsage.swift new file mode 100644 index 000000000..a983c1f32 --- /dev/null +++ b/Parra/Sessions/Types/ParraDiskUsage.swift @@ -0,0 +1,34 @@ +// +// ParraDiskUsage.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal struct ParraDiskUsage { + let totalCapacity: Int + let availableCapacity: Int + + // Capacity for storing essential resources + let availableEssentialCapacity: Int64 + + // Capacity for storing non-essential resources + let availableOpportunisticCapacity: Int64 + + init?(resourceValues: URLResourceValues) { + guard let totalCapacity = resourceValues.volumeTotalCapacity, + let availableCapacity = resourceValues.volumeAvailableCapacity, + let availableEssentialCapacity = resourceValues.volumeAvailableCapacityForImportantUsage, + let availableOpportunisticCapacity = resourceValues.volumeAvailableCapacityForOpportunisticUsage else { + return nil + } + + self.totalCapacity = totalCapacity + self.availableCapacity = availableCapacity + self.availableEssentialCapacity = availableEssentialCapacity + self.availableOpportunisticCapacity = availableOpportunisticCapacity + } +} diff --git a/Parra/Sessions/Types/ParraSanitizedDictionary.swift b/Parra/Sessions/Types/ParraSanitizedDictionary.swift new file mode 100644 index 000000000..9d5264716 --- /dev/null +++ b/Parra/Sessions/Types/ParraSanitizedDictionary.swift @@ -0,0 +1,47 @@ +// +// ParraSanitizedDictionary.swift +// Parra +// +// Created by Mick MacCallum on 9/14/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal struct ParraSanitizedDictionary: ExpressibleByDictionaryLiteral { + typealias Key = String + typealias Value = Any + + internal let dictionary: [Key : Value] + + internal init(dictionaryLiteral elements: (Key, Value)...) { + dictionary = Dictionary( + uniqueKeysWithValues: elements.map { key, value in + switch value { + case let url as URL: + return (key, ParraSanitizedDictionary.sanitize(url: url)) + default: + return (key, value) + } + } + ) + } + + internal init(dictionary: [String : Any]) { + self.dictionary = dictionary + } + + private static func sanitize(url: URL) -> URL { + // TODO: This method and others like it... + // Maybe values of query params become **** + return url + } +} + +extension ParraSanitizedDictionary: Encodable { + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + try container.encode(AnyCodable(dictionary)) + } +} diff --git a/Parra/Sessions/Types/ParraSanitizedDictionaryConvertible.swift b/Parra/Sessions/Types/ParraSanitizedDictionaryConvertible.swift new file mode 100644 index 000000000..b6ce701f3 --- /dev/null +++ b/Parra/Sessions/Types/ParraSanitizedDictionaryConvertible.swift @@ -0,0 +1,24 @@ +// +// ParraDictionaryConvertible.swift +// Parra +// +// Created by Mick MacCallum on 7/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +/// A helper for obtaining a dictionary representation of any conforming types. It is used +/// in places where the data returned may be logged, so any conforming type is expected to +/// return data that has been stripped of sensitive information from the ``sanitized`` property. +internal protocol ParraSanitizedDictionaryConvertible { + /// Any additional data that you would like to attach to the event. Useful for filtering and + /// viewing additional context about users producing the event in the dashboard. + var sanitized: ParraSanitizedDictionary { get } +} + +internal extension ParraSanitizedDictionaryConvertible { + var sanitized: ParraSanitizedDictionary { + return [:] + } +} diff --git a/Parra/Sessions/Types/ParraSession.swift b/Parra/Sessions/Types/ParraSession.swift new file mode 100644 index 000000000..2ee96bce9 --- /dev/null +++ b/Parra/Sessions/Types/ParraSession.swift @@ -0,0 +1,100 @@ +// +// ParraSession.swift +// Parra +// +// Created by Mick MacCallum on 12/29/22. +// Copyright © 2022 Parra, Inc. All rights reserved. +// + +import Foundation + +internal struct ParraSession: Codable, Equatable { + internal struct Constant { + static let packageExtension = "parsesh" + static let erroredSessionPrefix = "_" + } + + internal let sessionId: String + /// The SDK version at the time of session creation. Stored to handle cases where a session is created + /// and written to disk, then eventually loaded after an app update installs a newer version of the SDK. + internal let sdkVersion: String + internal let createdAt: Date + + internal private(set) var updatedAt: Date? + internal private(set) var endedAt: Date? + internal private(set) var userProperties: [String : AnyCodable] + + /// The byte offset of the file handle responsible for writing the events associated with this session + /// at the point where the last sync was initiated. This will be used to determine which events were + /// written since the last sync, as well as deleting events in the current session that have already + /// been synchronized. + internal private(set) var eventsHandleOffsetAtSync: UInt64? + + internal init( + sessionId: String, + createdAt: Date, + sdkVersion: String + ) { + self.sessionId = sessionId + self.createdAt = createdAt + self.sdkVersion = sdkVersion + self.updatedAt = nil + self.endedAt = nil + self.userProperties = [:] + } + + internal func hasBeenUpdated(since date: Date?) -> Bool { + guard let updatedAt else { + // If updatedAt isn't set, it hasn't been updated since it was created. + return false + } + + guard let date else { + return true + } + + return updatedAt > date + } + + // MARK: Performing Session Updates + + internal func withUpdates( + handler: ((inout ParraSession) -> Void)? = nil + ) -> ParraSession { + var updatedSession = self + + handler?(&updatedSession) + updatedSession.updatedAt = .now + + return updatedSession + } + + internal func withUpdatedProperty( + key: String, + value: Any? + ) -> ParraSession { + return withUpdates { session in + if let value { + session.userProperties[key] = AnyCodable(value) + } else { + session.userProperties.removeValue(forKey: key) + } + } + } + + internal func withUpdatedProperties( + newProperties: [String : AnyCodable] + ) -> ParraSession { + return withUpdates { session in + session.userProperties = newProperties + } + } + + internal func withUpdatedEventsHandleOffset( + offset: UInt64 + ) -> ParraSession { + return withUpdates { session in + session.eventsHandleOffsetAtSync = offset + } + } +} diff --git a/Parra/Sessions/Types/ParraSessionUpload.swift b/Parra/Sessions/Types/ParraSessionUpload.swift new file mode 100644 index 000000000..c881f28ba --- /dev/null +++ b/Parra/Sessions/Types/ParraSessionUpload.swift @@ -0,0 +1,32 @@ +// +// ParraSessionUpload.swift +// Parra +// +// Created by Mick MacCallum on 8/6/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +/// Events for a session are stored seperately and must be put together with the session +/// object before uploading it. +internal struct ParraSessionUpload: Encodable { + let session: ParraSession + let events: [ParraSessionEvent] + + internal enum CodingKeys: String, CodingKey { + case events + case userProperties + case startedAt + case endedAt + } + + internal func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(events, forKey: .events) + try container.encode(session.userProperties, forKey: .userProperties) + try container.encode(session.createdAt, forKey: .startedAt) + try container.encode(session.endedAt, forKey: .endedAt) + } +} diff --git a/Parra/Sessions/Types/StringManipulators.swift b/Parra/Sessions/Types/StringManipulators.swift new file mode 100644 index 000000000..795936590 --- /dev/null +++ b/Parra/Sessions/Types/StringManipulators.swift @@ -0,0 +1,19 @@ +// +// StringManipulators.swift +// ParraTests +// +// Created by Mick MacCallum on 6/25/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal struct StringManipulators { + static let hyphenAndSpaceCharset: CharacterSet = .whitespacesAndNewlines.union(.init(charactersIn: "-")) + + static func snakeCaseify(text: String) -> String { + return text.components( + separatedBy: StringManipulators.hyphenAndSpaceCharset + ).joined(separator: "_") + } +} diff --git a/Parra/State/ParraConfigState.swift b/Parra/State/ParraConfigState.swift index 32ebb59e8..b8801b234 100644 --- a/Parra/State/ParraConfigState.swift +++ b/Parra/State/ParraConfigState.swift @@ -7,30 +7,38 @@ // import Foundation +import UIKit -@globalActor -internal struct ParraConfigState { - internal static let shared = State() +internal actor ParraConfigState { + internal static nonisolated let defaultState: ParraConfiguration = .default - internal actor State { - private var currentState: ParraConfiguration = .default + private var currentState: ParraConfiguration = ParraConfigState.defaultState - fileprivate init() {} + internal init() { + self.currentState = ParraConfigState.defaultState + } - internal func getCurrentState() -> ParraConfiguration { - return currentState - } + internal init(currentState: ParraConfiguration) { + self.currentState = currentState + } - internal func updateState(_ newValue: ParraConfiguration) { - currentState = newValue + internal func getCurrentState() -> ParraConfiguration { + return currentState + } - ParraDefaultLogger.logQueue.async { - ParraDefaultLogger.default.loggerConfig = newValue.loggerConfig + // This should ONLY ever happen on initialization. Setting this elsewhere will require + // consideration for how ParraSessionManager receives its copy of the state. + internal func updateState(_ newValue: ParraConfiguration) { + currentState = newValue + + if newValue.pushNotificationsEnabled { + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() } } + } - internal func resetState() { - currentState = .default - } + internal func resetState() { + currentState = ParraConfigState.defaultState } } diff --git a/Parra/State/ParraGlobalState.swift b/Parra/State/ParraGlobalState.swift deleted file mode 100644 index 979a7b8ad..000000000 --- a/Parra/State/ParraGlobalState.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// ParraGlobalState.swift -// Parra -// -// Created by Mick MacCallum on 6/24/23. -// Copyright © 2023 Parra, Inc. All rights reserved. -// - -import Foundation - -@globalActor -internal struct ParraGlobalState { - internal static let shared = State() - - internal actor State { - /// Wether or not the SDK has been initialized by calling `Parra.initialize()` - private var initialized = false - - /// A push notification token that is being temporarily cached. Caching should only occur - /// for short periods until the SDK is prepared to upload it. Caching it longer term can - /// lead to invalid tokens being held onto for too long. - private var pushToken: String? - - private var registeredModules: [String: ParraModule] = [:] - - - fileprivate init() {} - - // MARK: Init - internal func isInitialized() -> Bool { - return initialized - } - - internal func initialize() { - initialized = true - } - - internal func deinitialize() { - initialized = false - } - - // MARK: - Parra Modules - - internal func getAllRegisteredModules() async -> [String: ParraModule] { - return registeredModules - } - - /// Registers the provided ParraModule with the Parra module. This exists for usage by other Parra modules only. It is used - /// to allow the Parra module to identify which other Parra modules have been installed. - internal func registerModule(module: ParraModule) { - registeredModules[type(of: module).name] = module - } - - // Mostly just a test helper - internal func unregisterModule(module: ParraModule) { - registeredModules.removeValue(forKey: type(of: module).name) - } - - /// Checks whether the provided module has already been registered with Parra - internal func hasRegisteredModule(module: ParraModule) -> Bool { - return registeredModules[type(of: module).name] != nil - } - - // MARK: - Push - internal func getCachedTemporaryPushToken() -> String? { - return pushToken - } - - internal func setTemporaryPushToken(_ token: String) { - pushToken = token - } - - internal func clearTemporaryPushToken() { - pushToken = nil - } - } -} diff --git a/Parra/State/ParraModuleStateAccessor.swift b/Parra/State/ParraModuleStateAccessor.swift new file mode 100644 index 000000000..e288833c4 --- /dev/null +++ b/Parra/State/ParraModuleStateAccessor.swift @@ -0,0 +1,14 @@ +// +// ParraModuleStateAccessor.swift +// Parra +// +// Created by Mick MacCallum on 7/2/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal protocol ParraModuleStateAccessor { + var state: ParraState { get } + var configState: ParraConfigState { get } +} diff --git a/Parra/State/ParraState.swift b/Parra/State/ParraState.swift new file mode 100644 index 000000000..326b7c01e --- /dev/null +++ b/Parra/State/ParraState.swift @@ -0,0 +1,125 @@ +// +// ParraState.swift +// Parra +// +// Created by Mick MacCallum on 6/24/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal actor ParraState { + private class ModuleObjectWrapper: NSObject { + fileprivate let module: ParraModule + + init(module: ParraModule) { + self.module = module + } + } + + /// Wether or not the SDK has been initialized by calling `Parra.initialize()` + private var initialized = false + + /// A push notification token that is being temporarily cached. Caching should only occur + /// for short periods until the SDK is prepared to upload it. Caching it longer term can + /// lead to invalid tokens being held onto for too long. + private var pushToken: String? + + private let registeredModules = NSMapTable( + keyOptions: .copyIn, + valueOptions: .strongMemory + ) + + internal init() {} + + internal init( + initialized: Bool = false, + pushToken: String? = nil, + registeredModules: [String : ParraModule] = [:] + ) { + self.initialized = initialized + self.pushToken = pushToken + + for (key, value) in registeredModules { + self.registeredModules.setObject( + ModuleObjectWrapper(module: value), + forKey: key as NSString + ) + } + } + + // MARK: Init + internal func isInitialized() -> Bool { + return initialized + } + + internal func initialize() { + initialized = true + } + + internal func deinitialize() { + initialized = false + } + + // MARK: - Parra Modules + + internal func getAllRegisteredModules() async -> [ParraModule] { + let modules = registeredModules.dictionaryRepresentation().values.map { wrapper in + return wrapper.module + } + + return modules + } + + /// Registers the provided ParraModule with the Parra module. This exists for + /// usage by other Parra modules only. It is used to allow the Parra module + /// to identify which other Parra modules have been installed. + internal func registerModule(module: ParraModule) { + let key = type(of: module).name as NSString + + registeredModules.setObject( + ModuleObjectWrapper(module: module), + forKey: key + ) + } + + internal func unregisterModule(module: ParraModule) async { + let key = type(of: module).name + + unregisterModule(named: key) + } + + nonisolated internal func unregisterModule(module: ParraModule) { + let key = type(of: module).name + + Task { + await unregisterModule(named: key) + } + } + + private func unregisterModule(named name: String) { + registeredModules.removeObject( + forKey: name as NSString + ) + } + + /// Checks whether the provided module has already been registered with Parra + internal func hasRegisteredModule(module: ParraModule) -> Bool { + let key = type(of: module).name as NSString + + return registeredModules.object(forKey: key) != nil + } + + // MARK: - Push + internal func getCachedTemporaryPushToken() -> String? { + return pushToken + } + + internal func setTemporaryPushToken(_ token: String) { + pushToken = token + } + + internal func clearTemporaryPushToken() { + pushToken = nil + } +} diff --git a/Parra/State/ParraSyncState.swift b/Parra/State/ParraSyncState.swift new file mode 100644 index 000000000..156def158 --- /dev/null +++ b/Parra/State/ParraSyncState.swift @@ -0,0 +1,26 @@ +// +// ParraSyncState.swift +// Parra +// +// Created by Mick MacCallum on 6/24/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal actor ParraSyncState { + /// Whether or not a sync operation is in progress. + private var syncing = false + + internal func isSyncing() -> Bool { + return syncing + } + + internal func beginSync() { + syncing = true + } + + internal func endSync() { + syncing = false + } +} diff --git a/Parra/State/SyncState.swift b/Parra/State/SyncState.swift deleted file mode 100644 index 51af55146..000000000 --- a/Parra/State/SyncState.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// SyncState.swift -// Parra -// -// Created by Mick MacCallum on 6/24/23. -// Copyright © 2023 Parra, Inc. All rights reserved. -// - -import Foundation - -@globalActor -internal struct SyncState { - internal static let shared = State() - - internal actor State { - /// Whether or not a sync operation is in progress. - private var syncing = false - - fileprivate init() {} - - internal func isSyncing() -> Bool { - return syncing - } - - internal func beginSync() { - syncing = true - } - - internal func endSync() { - syncing = false - } - } -} diff --git a/Parra/Supporting Files/Pacifico-Regular.ttf b/Parra/Supporting Files/Pacifico-Regular.ttf deleted file mode 100644 index b9265a2a5..000000000 Binary files a/Parra/Supporting Files/Pacifico-Regular.ttf and /dev/null differ diff --git a/Parra/Parra.h b/Parra/Supporting Files/Parra.h similarity index 100% rename from Parra/Parra.h rename to Parra/Supporting Files/Parra.h diff --git a/Parra/Supporting Files/ParraAssets.xcassets/Contents.json b/Parra/Supporting Files/ParraAssets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Parra/Supporting Files/ParraAssets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Parra/Supporting Files/ParraAssets.xcassets/ParraLogo.imageset/Contents.json b/Parra/Supporting Files/ParraAssets.xcassets/ParraLogo.imageset/Contents.json new file mode 100644 index 000000000..ad42a58df --- /dev/null +++ b/Parra/Supporting Files/ParraAssets.xcassets/ParraLogo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "logo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Parra/Supporting Files/ParraAssets.xcassets/ParraLogo.imageset/logo.svg b/Parra/Supporting Files/ParraAssets.xcassets/ParraLogo.imageset/logo.svg new file mode 100644 index 000000000..6b38d33a8 --- /dev/null +++ b/Parra/Supporting Files/ParraAssets.xcassets/ParraLogo.imageset/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Parra/Supporting Files/ParraAssets.xcassets/ParraName.imageset/Contents.json b/Parra/Supporting Files/ParraAssets.xcassets/ParraName.imageset/Contents.json new file mode 100644 index 000000000..c76d0aacf --- /dev/null +++ b/Parra/Supporting Files/ParraAssets.xcassets/ParraName.imageset/Contents.json @@ -0,0 +1,56 @@ +{ + "images" : [ + { + "filename" : "Parra.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Parra 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Parra@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Parra@2x 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Parra@3x.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Parra@3x 1.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Parra/Supporting Files/ParraAssets.xcassets/ParraName.imageset/Parra 1.png b/Parra/Supporting Files/ParraAssets.xcassets/ParraName.imageset/Parra 1.png new file mode 100644 index 000000000..2b9648b17 Binary files /dev/null and b/Parra/Supporting Files/ParraAssets.xcassets/ParraName.imageset/Parra 1.png differ diff --git a/Parra/Supporting Files/ParraAssets.xcassets/ParraName.imageset/Parra.png b/Parra/Supporting Files/ParraAssets.xcassets/ParraName.imageset/Parra.png new file mode 100644 index 000000000..e4340e6eb Binary files /dev/null and b/Parra/Supporting Files/ParraAssets.xcassets/ParraName.imageset/Parra.png differ diff --git a/Parra/Supporting Files/ParraAssets.xcassets/ParraName.imageset/Parra@2x 1.png b/Parra/Supporting Files/ParraAssets.xcassets/ParraName.imageset/Parra@2x 1.png new file mode 100644 index 000000000..3225b5719 Binary files /dev/null and b/Parra/Supporting Files/ParraAssets.xcassets/ParraName.imageset/Parra@2x 1.png differ diff --git a/Parra/Supporting Files/ParraAssets.xcassets/ParraName.imageset/Parra@2x.png b/Parra/Supporting Files/ParraAssets.xcassets/ParraName.imageset/Parra@2x.png new file mode 100644 index 000000000..345d872ae Binary files /dev/null and b/Parra/Supporting Files/ParraAssets.xcassets/ParraName.imageset/Parra@2x.png differ diff --git a/Parra/Supporting Files/ParraAssets.xcassets/ParraName.imageset/Parra@3x 1.png b/Parra/Supporting Files/ParraAssets.xcassets/ParraName.imageset/Parra@3x 1.png new file mode 100644 index 000000000..03bc61741 Binary files /dev/null and b/Parra/Supporting Files/ParraAssets.xcassets/ParraName.imageset/Parra@3x 1.png differ diff --git a/Parra/Supporting Files/ParraAssets.xcassets/ParraName.imageset/Parra@3x.png b/Parra/Supporting Files/ParraAssets.xcassets/ParraName.imageset/Parra@3x.png new file mode 100644 index 000000000..fc530aeb5 Binary files /dev/null and b/Parra/Supporting Files/ParraAssets.xcassets/ParraName.imageset/Parra@3x.png differ diff --git a/Parra/Supporting Files/ParraAssets.xcassets/PoweredByParra.imageset/Contents.json b/Parra/Supporting Files/ParraAssets.xcassets/PoweredByParra.imageset/Contents.json new file mode 100644 index 000000000..6cd8281e4 --- /dev/null +++ b/Parra/Supporting Files/ParraAssets.xcassets/PoweredByParra.imageset/Contents.json @@ -0,0 +1,56 @@ +{ + "images" : [ + { + "filename" : "Powered by Parra 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Powered by Parra.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Powered by Parra@2x 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Powered by Parra@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Powered by Parra@3x 1.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Powered by Parra@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Parra/Supporting Files/ParraAssets.xcassets/PoweredByParra.imageset/Powered by Parra 1.png b/Parra/Supporting Files/ParraAssets.xcassets/PoweredByParra.imageset/Powered by Parra 1.png new file mode 100644 index 000000000..5005cfc35 Binary files /dev/null and b/Parra/Supporting Files/ParraAssets.xcassets/PoweredByParra.imageset/Powered by Parra 1.png differ diff --git a/Parra/Supporting Files/ParraAssets.xcassets/PoweredByParra.imageset/Powered by Parra.png b/Parra/Supporting Files/ParraAssets.xcassets/PoweredByParra.imageset/Powered by Parra.png new file mode 100644 index 000000000..abcc7d4e2 Binary files /dev/null and b/Parra/Supporting Files/ParraAssets.xcassets/PoweredByParra.imageset/Powered by Parra.png differ diff --git a/Parra/Supporting Files/ParraAssets.xcassets/PoweredByParra.imageset/Powered by Parra@2x 1.png b/Parra/Supporting Files/ParraAssets.xcassets/PoweredByParra.imageset/Powered by Parra@2x 1.png new file mode 100644 index 000000000..659fcf01b Binary files /dev/null and b/Parra/Supporting Files/ParraAssets.xcassets/PoweredByParra.imageset/Powered by Parra@2x 1.png differ diff --git a/Parra/Supporting Files/ParraAssets.xcassets/PoweredByParra.imageset/Powered by Parra@2x.png b/Parra/Supporting Files/ParraAssets.xcassets/PoweredByParra.imageset/Powered by Parra@2x.png new file mode 100644 index 000000000..897c39dfe Binary files /dev/null and b/Parra/Supporting Files/ParraAssets.xcassets/PoweredByParra.imageset/Powered by Parra@2x.png differ diff --git a/Parra/Supporting Files/ParraAssets.xcassets/PoweredByParra.imageset/Powered by Parra@3x 1.png b/Parra/Supporting Files/ParraAssets.xcassets/PoweredByParra.imageset/Powered by Parra@3x 1.png new file mode 100644 index 000000000..d8485cbe0 Binary files /dev/null and b/Parra/Supporting Files/ParraAssets.xcassets/PoweredByParra.imageset/Powered by Parra@3x 1.png differ diff --git a/Parra/Supporting Files/ParraAssets.xcassets/PoweredByParra.imageset/Powered by Parra@3x.png b/Parra/Supporting Files/ParraAssets.xcassets/PoweredByParra.imageset/Powered by Parra@3x.png new file mode 100644 index 000000000..a40084077 Binary files /dev/null and b/Parra/Supporting Files/ParraAssets.xcassets/PoweredByParra.imageset/Powered by Parra@3x.png differ diff --git a/Parra/Types/AnyCodable/AnyCodable.swift b/Parra/Types/AnyCodable/AnyCodable.swift index 3848ae836..ddd5bb52b 100644 --- a/Parra/Types/AnyCodable/AnyCodable.swift +++ b/Parra/Types/AnyCodable/AnyCodable.swift @@ -61,11 +61,11 @@ extension AnyCodable: Equatable { return lhs == rhs case let (lhs as String, rhs as String): return lhs == rhs - case let (lhs as [String: AnyCodable], rhs as [String: AnyCodable]): + case let (lhs as [String : AnyCodable], rhs as [String : AnyCodable]): return lhs == rhs case let (lhs as [AnyCodable], rhs as [AnyCodable]): return lhs == rhs - case let (lhs as [String: Any], rhs as [String: Any]): + case let (lhs as [String : Any], rhs as [String : Any]): return NSDictionary(dictionary: lhs) == NSDictionary(dictionary: rhs) case let (lhs as [Any], rhs as [Any]): return NSArray(array: lhs) == NSArray(array: rhs) @@ -142,7 +142,7 @@ extension AnyCodable: Hashable { hasher.combine(value) case let value as String: hasher.combine(value) - case let value as [String: AnyCodable]: + case let value as [String : AnyCodable]: hasher.combine(value) case let value as [AnyCodable]: hasher.combine(value) diff --git a/Parra/Types/AnyCodable/AnyDecodable.swift b/Parra/Types/AnyCodable/AnyDecodable.swift index 40ea4c914..35d696275 100644 --- a/Parra/Types/AnyCodable/AnyDecodable.swift +++ b/Parra/Types/AnyCodable/AnyDecodable.swift @@ -35,7 +35,7 @@ import Foundation """.data(using: .utf8)! let decoder = JSONDecoder() - let dictionary = try! decoder.decode([String: AnyDecodable].self, from: json) + let dictionary = try! decoder.decode([String : AnyDecodable].self, from: json) */ @frozen public struct AnyDecodable: Decodable { public let value: Any @@ -75,7 +75,7 @@ extension _AnyDecodable { self.init(string) } else if let array = try? container.decode([AnyDecodable].self) { self.init(array.map { $0.value }) - } else if let dictionary = try? container.decode([String: AnyDecodable].self) { + } else if let dictionary = try? container.decode([String : AnyDecodable].self) { self.init(dictionary.mapValues { $0.value }) } else { throw DecodingError.dataCorruptedError(in: container, debugDescription: "AnyDecodable value cannot be decoded") @@ -118,7 +118,7 @@ extension AnyDecodable: Equatable { return lhs == rhs case let (lhs as String, rhs as String): return lhs == rhs - case let (lhs as [String: AnyDecodable], rhs as [String: AnyDecodable]): + case let (lhs as [String : AnyDecodable], rhs as [String : AnyDecodable]): return lhs == rhs case let (lhs as [AnyDecodable], rhs as [AnyDecodable]): return lhs == rhs @@ -183,7 +183,7 @@ extension AnyDecodable: Hashable { hasher.combine(value) case let value as String: hasher.combine(value) - case let value as [String: AnyDecodable]: + case let value as [String : AnyDecodable]: hasher.combine(value) case let value as [AnyDecodable]: hasher.combine(value) diff --git a/Parra/Types/AnyCodable/AnyEncodable.swift b/Parra/Types/AnyCodable/AnyEncodable.swift index 4427ef58f..77f61fde3 100644 --- a/Parra/Types/AnyCodable/AnyEncodable.swift +++ b/Parra/Types/AnyCodable/AnyEncodable.swift @@ -18,7 +18,7 @@ import Foundation and other collections that require `Encodable` conformance by declaring their contained type to be `AnyEncodable`: - let dictionary: [String: AnyEncodable] = [ + let dictionary: [String : AnyEncodable] = [ "boolean": true, "integer": 42, "double": 3.141592653589793, @@ -102,7 +102,7 @@ extension _AnyEncodable { #endif case let array as [Any?]: try container.encode(array.map { AnyEncodable($0) }) - case let dictionary as [String: Any?]: + case let dictionary as [String : Any?]: try container.encode(dictionary.mapValues { AnyEncodable($0) }) case let encodable as Encodable: try encodable.encode(to: encoder) @@ -178,7 +178,7 @@ extension AnyEncodable: Equatable { return lhs == rhs case let (lhs as String, rhs as String): return lhs == rhs - case let (lhs as [String: AnyEncodable], rhs as [String: AnyEncodable]): + case let (lhs as [String : AnyEncodable], rhs as [String : AnyEncodable]): return lhs == rhs case let (lhs as [AnyEncodable], rhs as [AnyEncodable]): return lhs == rhs @@ -286,7 +286,7 @@ extension AnyEncodable: Hashable { hasher.combine(value) case let value as String: hasher.combine(value) - case let value as [String: AnyEncodable]: + case let value as [String : AnyEncodable]: hasher.combine(value) case let value as [AnyEncodable]: hasher.combine(value) diff --git a/Parra/Types/Card Completion/CompletedCard.swift b/Parra/Types/Card Completion/CompletedCard.swift index 0499ca02b..f5f131171 100644 --- a/Parra/Types/Card Completion/CompletedCard.swift +++ b/Parra/Types/Card Completion/CompletedCard.swift @@ -8,12 +8,12 @@ import Foundation // Completed card needs to be able to be converted to and from JSON for storage on disk. -public struct CompletedCard: Codable { - public let bucketItemId: String - public let questionId: String - public let data: QuestionAnswer - - public init( +internal struct CompletedCard: Codable { + internal let bucketItemId: String + internal let questionId: String + internal let data: QuestionAnswer + + internal init( bucketItemId: String, questionId: String, data: QuestionAnswer diff --git a/Parra/Types/Card Completion/QuestionAnswer.swift b/Parra/Types/Card Completion/QuestionAnswer.swift index 48a55f259..1efb485ae 100644 --- a/Parra/Types/Card Completion/QuestionAnswer.swift +++ b/Parra/Types/Card Completion/QuestionAnswer.swift @@ -8,49 +8,49 @@ import Foundation -public protocol AnswerOption: Codable {} +internal protocol AnswerOption: Codable {} -public struct SingleOptionAnswer: AnswerOption { - public let optionId: String +internal struct SingleOptionAnswer: AnswerOption { + internal let optionId: String - public init(optionId: String) { + internal init(optionId: String) { self.optionId = optionId } } -public struct MultiOptionIndividualOption: Codable { - public let id: String +internal struct MultiOptionIndividualOption: Codable { + internal let id: String - public init(id: String) { + internal init(id: String) { self.id = id } } -public struct MultiOptionAnswer: AnswerOption { - public let options: [MultiOptionIndividualOption] +internal struct MultiOptionAnswer: AnswerOption { + internal let options: [MultiOptionIndividualOption] - public init(options: [MultiOptionIndividualOption]) { + internal init(options: [MultiOptionIndividualOption]) { self.options = options } } -public struct TextValueAnswer: AnswerOption { - public let value: String +internal struct TextValueAnswer: AnswerOption { + internal let value: String - public init(value: String) { + internal init(value: String) { self.value = value } } -public struct IntValueAnswer: AnswerOption { - public let value: Int +internal struct IntValueAnswer: AnswerOption { + internal let value: Int - public init(value: Int) { + internal init(value: Int) { self.value = value } } -public enum QuestionAnswerKind: Codable { +internal enum QuestionAnswerKind: Codable { case checkbox(MultiOptionAnswer) case radio(SingleOptionAnswer) case boolean(SingleOptionAnswer) @@ -61,16 +61,16 @@ public enum QuestionAnswerKind: Codable { case textLong(TextValueAnswer) } -public struct QuestionAnswer: Codable { - public let kind: QuestionKind - public let data: any AnswerOption +internal struct QuestionAnswer: Codable { + internal let kind: QuestionKind + internal let data: any AnswerOption - public init(kind: QuestionKind, data: AnswerOption) { + internal init(kind: QuestionKind, data: AnswerOption) { self.kind = kind self.data = data } - public init(from decoder: Decoder) throws { + internal init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.kind = try container.decode(QuestionKind.self, forKey: .kind) @@ -91,7 +91,7 @@ public struct QuestionAnswer: Codable { case data } - public func encode(to encoder: Encoder) throws { + internal func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(kind, forKey: .kind) diff --git a/Parra/Types/Configuration/ParraConfiguration.swift b/Parra/Types/Configuration/ParraConfiguration.swift index a471bd5b0..9b62baf2b 100644 --- a/Parra/Types/Configuration/ParraConfiguration.swift +++ b/Parra/Types/Configuration/ParraConfiguration.swift @@ -9,21 +9,50 @@ import Foundation public struct ParraConfiguration { - public let loggerConfig: ParraLoggerConfig + public let loggerOptions: ParraLoggerOptions + public let pushNotificationsEnabled: Bool + public internal(set) var tenantId: String? public internal(set) var applicationId: String? - // Public version of this initializer should be kept up to date to include - // all fields except for tenantId and applicationId - public init(loggerConfig: ParraLoggerConfig) { - self.loggerConfig = loggerConfig + internal init( + loggerOptions: ParraLoggerOptions, + pushNotificationsEnabled: Bool + ) { + self.loggerOptions = loggerOptions + self.pushNotificationsEnabled = pushNotificationsEnabled + self.tenantId = nil self.applicationId = nil } - public static let `default` = ParraConfiguration( - loggerConfig: .default - ) + internal init(options: [ParraConfigurationOption]) { + var loggerOptions = ParraConfiguration.default.loggerOptions + var pushNotificationsEnabled = ParraConfiguration.default.pushNotificationsEnabled + + for option in options { + switch option { + case .logger(let logOptions): + loggerOptions = ParraConfiguration.applyLoggerOptionsOverrides( + loggerOptions: logOptions + ) + case .pushNotifications: + pushNotificationsEnabled = true + } + } + + self.loggerOptions = loggerOptions + self.pushNotificationsEnabled = pushNotificationsEnabled + } + + public static let `default`: ParraConfiguration = { + return ParraConfiguration( + loggerOptions: applyLoggerOptionsOverrides( + loggerOptions: .default + ), + pushNotificationsEnabled: false + ) + }() internal mutating func setTenantId(_ tenantId: String?) { self.tenantId = tenantId @@ -32,4 +61,23 @@ public struct ParraConfiguration { internal mutating func setApplicationId(_ applicationId: String?) { self.applicationId = applicationId } + + private static func applyLoggerOptionsOverrides( + loggerOptions initialOptions: ParraLoggerOptions + ) -> ParraLoggerOptions { + var loggerOptions = initialOptions + + let envVar = ParraLoggerOptions.Environment.minimumLogLevelOverride + if let rawLevelOverride = ProcessInfo.processInfo.environment[envVar], + let level = ParraLogLevel(name: rawLevelOverride) { + + Logger.info( + "\(envVar) is set. Changing minimum log level from \(initialOptions.minimumLogLevel.name) to \(level.name)" + ) + + loggerOptions.minimumLogLevel = level + } + + return loggerOptions + } } diff --git a/Parra/Types/Configuration/ParraConfigurationOption.swift b/Parra/Types/Configuration/ParraConfigurationOption.swift new file mode 100644 index 000000000..73f138357 --- /dev/null +++ b/Parra/Types/Configuration/ParraConfigurationOption.swift @@ -0,0 +1,22 @@ +// +// ParraConfigurationOption.swift +// Parra +// +// Created by Mick MacCallum on 7/2/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +public enum ParraConfigurationOption { + + /// Options to configure the Parra Logger. If no value is set here, + /// you can still use the logger with its default options. + case logger(options: ParraLoggerOptions) + + /// Whether or not Parra should register the device for push notifications. + /// This in turn, will trigger the UIApplicationDelegate methods related + /// to push permissions, and should be done if you intend to use + /// `Parra.registerDevicePushToken(_:)` + case pushNotifications +} diff --git a/Parra/Types/GeneratedTypes.swift b/Parra/Types/GeneratedTypes.swift index fdc19c518..f68edbeac 100644 --- a/Parra/Types/GeneratedTypes.swift +++ b/Parra/Types/GeneratedTypes.swift @@ -304,7 +304,7 @@ public enum FeedbackFormFieldType: String, Codable { public struct FeedbackFormField: Codable, Equatable, Hashable, Identifiable { public var id: String { - name + return name } public let name: String @@ -348,11 +348,17 @@ public struct FeedbackFormField: Codable, Equatable, Hashable, Identifiable { self.required = try container.decode(Bool.self, forKey: .required) switch type { case .input: - self.data = .feedbackFormInputFieldData(try container.decode(FeedbackFormInputFieldData.self, forKey: .data)) + self.data = .feedbackFormInputFieldData( + try container.decode(FeedbackFormInputFieldData.self, forKey: .data) + ) case .text: - self.data = .feedbackFormTextFieldData(try container.decode(FeedbackFormTextFieldData.self, forKey: .data)) + self.data = .feedbackFormTextFieldData( + try container.decode(FeedbackFormTextFieldData.self, forKey: .data) + ) case .select: - self.data = .feedbackFormSelectFieldData(try container.decode(FeedbackFormSelectFieldData.self, forKey: .data)) + self.data = .feedbackFormSelectFieldData( + try container.decode(FeedbackFormSelectFieldData.self, forKey: .data) + ) } } } @@ -375,6 +381,7 @@ public struct FeedbackFormTextFieldData: Codable, Equatable, Hashable, FeedbackF public init( placeholder: String?, + // TODO: Remove lines/maxLines lines: Int?, maxLines: Int?, minCharacters: Int?, @@ -705,7 +712,7 @@ public struct CardsResponse: Codable, Equatable, Hashable { return base case .failure(let error): let debugError = (error as CustomDebugStringConvertible).debugDescription - parraLogWarn("CardsResponse error parsing card", [NSLocalizedDescriptionKey: debugError]) + Logger.warn("CardsResponse error parsing card", [NSLocalizedDescriptionKey: debugError]) return nil } } diff --git a/Parra/Types/Network/AuthenticatedRequestAttributeOptions.swift b/Parra/Types/Network/AuthenticatedRequestAttributeOptions.swift index 88bc3237a..f35773d6b 100644 --- a/Parra/Types/Network/AuthenticatedRequestAttributeOptions.swift +++ b/Parra/Types/Network/AuthenticatedRequestAttributeOptions.swift @@ -8,14 +8,22 @@ import Foundation -public struct AuthenticatedRequestAttributeOptions: OptionSet { - public let rawValue: Int +internal struct AuthenticatedRequestAttributeOptions: OptionSet { + internal let rawValue: Int - public static let requiredReauthentication = AuthenticatedRequestAttributeOptions(rawValue: 1 << 0) - public static let requiredRetry = AuthenticatedRequestAttributeOptions(rawValue: 1 << 1) - public static let exceededRetryLimit = AuthenticatedRequestAttributeOptions(rawValue: 1 << 2) + internal static let requiredReauthentication = AuthenticatedRequestAttributeOptions( + rawValue: 1 << 0 + ) + + internal static let requiredRetry = AuthenticatedRequestAttributeOptions( + rawValue: 1 << 1 + ) + + internal static let exceededRetryLimit = AuthenticatedRequestAttributeOptions( + rawValue: 1 << 2 + ) - public init(rawValue: Int) { + internal init(rawValue: Int) { self.rawValue = rawValue } } diff --git a/Parra/Types/Network/AuthenticatedRequestResult.swift b/Parra/Types/Network/AuthenticatedRequestResult.swift index 2da3aa43d..dc3cdf679 100644 --- a/Parra/Types/Network/AuthenticatedRequestResult.swift +++ b/Parra/Types/Network/AuthenticatedRequestResult.swift @@ -8,13 +8,14 @@ import Foundation -public struct AuthenticatedRequestResult { - public let result: Result - public let attributes: AuthenticatedRequestAttributeOptions - - init(result: Result, - responseAttributes: AuthenticatedRequestAttributeOptions = []) { +internal struct AuthenticatedRequestResult { + internal let result: Result + internal let attributes: AuthenticatedRequestAttributeOptions + internal init( + result: Result, + responseAttributes: AuthenticatedRequestAttributeOptions = [] + ) { self.result = result self.attributes = responseAttributes } diff --git a/Parra/Types/Network/AuthorizationType.swift b/Parra/Types/Network/AuthorizationType.swift new file mode 100644 index 000000000..4a9cfb051 --- /dev/null +++ b/Parra/Types/Network/AuthorizationType.swift @@ -0,0 +1,23 @@ +// +// AuthorizationType.swift +// Parra +// +// Created by Mick MacCallum on 7/2/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal enum AuthorizationType { + case basic(String) + case bearer(String) + + var value: String { + switch self { + case .basic(let token): + return "Basic \(token)" + case .bearer(let token): + return "Bearer \(token)" + } + } +} diff --git a/Parra/Types/Network/EmptyJsonObjects.swift b/Parra/Types/Network/EmptyJsonObjects.swift new file mode 100644 index 000000000..8a120e1bc --- /dev/null +++ b/Parra/Types/Network/EmptyJsonObjects.swift @@ -0,0 +1,14 @@ +// +// EmptyJsonObjects.swift +// Parra +// +// Created by Mick MacCallum on 7/4/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal let EmptyJsonObjectData = "{}".data(using: .utf8)! + +internal struct EmptyRequestObject: Codable {} +internal struct EmptyResponseObject: Codable {} diff --git a/Parra/Types/Network/Endpoint+Mocks.swift b/Parra/Types/Network/Endpoint+Mocks.swift new file mode 100644 index 000000000..fce6b84de --- /dev/null +++ b/Parra/Types/Network/Endpoint+Mocks.swift @@ -0,0 +1,42 @@ +// +// Endpoint+Mocks.swift +// Parra +// +// Created by Mick MacCallum on 7/2/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation +@testable import Parra + +extension ParraEndpoint { + func getMockResponseData(for status: Int = 200) throws -> Data { + let object: Codable + switch status { + case 200..<300: + switch self { + case .getCards: + object = TestData.Cards.cardsResponse + case .getFeedbackForm(let formId): + object = TestData.Forms.formResponse(formId: formId) + case .postSubmitFeedbackForm: + object = EmptyResponseObject() + case .postBulkAnswerQuestions: + object = EmptyResponseObject() + case .postBulkSubmitSessions: + object = TestData.Sessions.successResponse + case .postPushTokens: + object = EmptyResponseObject() + case .postAuthentication: + object = TestData.Auth.successResponse + } + default: + throw ParraError.generic( + "getMockResponseData has no implemented return for status: \(status) for endpoint: \(slug)", + nil + ) + } + + return try JSONEncoder.parraEncoder.encode(object) + } +} diff --git a/Parra/Types/Network/Endpoint.swift b/Parra/Types/Network/Endpoint.swift index ed4baa7e7..be689baa3 100644 --- a/Parra/Types/Network/Endpoint.swift +++ b/Parra/Types/Network/Endpoint.swift @@ -9,9 +9,6 @@ import Foundation internal enum ParraEndpoint { - // mostly just for testing - case custom(route: String, method: HttpMethod) - // Auth case postAuthentication(tenantId: String) @@ -30,8 +27,6 @@ internal enum ParraEndpoint { // All endpoints should use kebab case! var route: String { switch self { - case .custom(let route, _): - return route case .getCards: return "cards" case .getFeedbackForm(let formId): @@ -51,8 +46,6 @@ internal enum ParraEndpoint { var method: HttpMethod { switch self { - case .custom(_, let method): - return method case .getCards, .getFeedbackForm: return .get case .postBulkAnswerQuestions, .postSubmitFeedbackForm, .postBulkSubmitSessions, @@ -76,8 +69,6 @@ internal enum ParraEndpoint { var slug: String { switch self { - case .custom(let route, _): - return route case .getCards: return "cards" case .getFeedbackForm: diff --git a/Parra/Types/Network/Mimetype.swift b/Parra/Types/Network/Mimetype.swift new file mode 100644 index 000000000..b86b35a39 --- /dev/null +++ b/Parra/Types/Network/Mimetype.swift @@ -0,0 +1,13 @@ +// +// Mimetype.swift +// Parra +// +// Created by Mick MacCallum on 7/2/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal enum Mimetype: String { + case applicationJson = "application/json" +} diff --git a/Parra/Types/Network/NetworkManagerType.swift b/Parra/Types/Network/NetworkManagerType.swift index d1f70271f..be3564473 100644 --- a/Parra/Types/Network/NetworkManagerType.swift +++ b/Parra/Types/Network/NetworkManagerType.swift @@ -8,8 +8,10 @@ import Foundation -internal protocol NetworkManagerType { +internal protocol NetworkManagerType: ParraModuleStateAccessor { init( + state: ParraState, + configState: ParraConfigState, dataManager: ParraDataManager, urlSession: URLSessionType, jsonEncoder: JSONEncoder, diff --git a/Parra/Types/Network/ParraHeader.swift b/Parra/Types/Network/ParraHeader.swift index 96475884b..0504b127a 100644 --- a/Parra/Types/Network/ParraHeader.swift +++ b/Parra/Types/Network/ParraHeader.swift @@ -84,7 +84,7 @@ internal enum ParraHeader { case .deviceId: return UIDevice.current.identifierForVendor?.uuidString case .deviceLocale: - return NSLocale.current.languageCode + return NSLocale.current.language.languageCode?.identifier case .deviceManufacturer: return "Apple" case .deviceTimeZoneAbbreviation: @@ -98,18 +98,18 @@ internal enum ParraHeader { case .platformSdkVersion: return Parra.libraryVersion() case .platformVersion: - return UIDevice.current.systemVersion + return ProcessInfo.processInfo.operatingSystemVersionString } } - static var trackingHeaderDictionary: [String: String] { + static var trackingHeaderDictionary: [String : String] { let keys: [ParraHeader] = [ .applicationLocale, .applicationBundleId, .debug, .device, .deviceId, .deviceLocale, .deviceManufacturer, .deviceTimeZoneAbbreviation, .deviceTimeZoneOffset, .platform, .platformAgent, .platformSdkVersion, .platformVersion ] - var headers = [String: String]() + var headers = [String : String]() for key in keys { if let value = key.currentValue { diff --git a/Parra/Types/Network/ParraNetworkManagerUrlSessionDelegateProxy.swift b/Parra/Types/Network/ParraNetworkManagerUrlSessionDelegateProxy.swift new file mode 100644 index 000000000..6eea81c6f --- /dev/null +++ b/Parra/Types/Network/ParraNetworkManagerUrlSessionDelegateProxy.swift @@ -0,0 +1,32 @@ +// +// ParraNetworkManagerUrlSessionDelegateProxy.swift +// Parra +// +// Created by Mick MacCallum on 7/4/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal class ParraNetworkManagerUrlSessionDelegateProxy: NSObject, URLSessionTaskDelegate { + + weak private var delegate: ParraUrlSessionDelegate? + + init(delegate: ParraUrlSessionDelegate) { + self.delegate = delegate + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didFinishCollecting metrics: URLSessionTaskMetrics + ) { + Task { + await delegate?.urlSession( + session, + task: task, + didFinishCollecting: metrics + ) + } + } +} diff --git a/Parra/Types/Network/ParraUrlSessionDelegate.swift b/Parra/Types/Network/ParraUrlSessionDelegate.swift new file mode 100644 index 000000000..6b56a1582 --- /dev/null +++ b/Parra/Types/Network/ParraUrlSessionDelegate.swift @@ -0,0 +1,17 @@ +// +// ParraUrlSessionDelegate.swift +// Parra +// +// Created by Mick MacCallum on 7/4/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal protocol ParraUrlSessionDelegate: AnyObject { + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didFinishCollecting metrics: URLSessionTaskMetrics + ) async +} diff --git a/Parra/Types/Network/RequestConfig.swift b/Parra/Types/Network/RequestConfig.swift index 40b799975..ee0dbd927 100644 --- a/Parra/Types/Network/RequestConfig.swift +++ b/Parra/Types/Network/RequestConfig.swift @@ -39,11 +39,11 @@ internal struct RequestConfig { return remainingTries > 1 } - var retryDelayNs: UInt64 { + var retryDelay: TimeInterval { let diff = allowedTries - remainingTries if diff > 0 { - return UInt64(pow(2.0, Double(diff))) * 1_000_000_000 + return pow(2.0, TimeInterval(diff)) } return 0 diff --git a/Parra/Types/Network/URLRequestHeaderField.swift b/Parra/Types/Network/URLRequestHeaderField.swift new file mode 100644 index 000000000..a90d7313b --- /dev/null +++ b/Parra/Types/Network/URLRequestHeaderField.swift @@ -0,0 +1,37 @@ +// +// URLRequestHeaderField.swift +// Parra +// +// Created by Mick MacCallum on 7/2/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal enum URLRequestHeaderField { + case authorization(AuthorizationType) + case accept(Mimetype) + case contentType(Mimetype) + + var name: String { + switch self { + case .authorization: + return "Authorization" + case .accept: + return "Accept" + case .contentType: + return "Content-Type" + } + } + + var value: String { + switch self { + case .authorization(let authorizationType): + return authorizationType.value + case .accept(let mimetype): + return mimetype.rawValue + case .contentType(let mimetype): + return mimetype.rawValue + } + } +} diff --git a/Parra/Types/NotificationCenterType.swift b/Parra/Types/NotificationCenterType.swift index 3962853c7..e5e2f0092 100644 --- a/Parra/Types/NotificationCenterType.swift +++ b/Parra/Types/NotificationCenterType.swift @@ -8,7 +8,7 @@ import Foundation -public protocol NotificationCenterType { +internal protocol NotificationCenterType { func post( name aName: NSNotification.Name, object anObject: Any?, @@ -34,4 +34,10 @@ public protocol NotificationCenterType { queue: OperationQueue?, using block: @escaping @Sendable (Notification) -> Void ) -> NSObjectProtocol + + func removeObserver( + _ observer: Any, + name aName: NSNotification.Name?, + object anObject: Any? + ) } diff --git a/Parra/Types/ParraCredential.swift b/Parra/Types/ParraCredential.swift index 9d7e61423..80a42870c 100644 --- a/Parra/Types/ParraCredential.swift +++ b/Parra/Types/ParraCredential.swift @@ -21,7 +21,9 @@ public struct ParraCredential: Codable, Equatable { } public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) + let container = try decoder.container( + keyedBy: CodingKeys.self + ) if let token = try? container.decode(String.self, forKey: .token) { self.token = token diff --git a/Parra/Types/ParraError.swift b/Parra/Types/ParraError.swift new file mode 100644 index 000000000..ea3cf66a8 --- /dev/null +++ b/Parra/Types/ParraError.swift @@ -0,0 +1,128 @@ +// +// ParraError.swift +// Parra +// +// Created by Michael MacCallum on 2/26/22. +// + +import Foundation + +public enum ParraError: LocalizedError, CustomStringConvertible { + case message(String) + case generic(String, Error?) + case notInitialized + case missingAuthentication + case authenticationFailed(String) + case networkError( + request: URLRequest, + response: HTTPURLResponse, + responseData: Data + ) + case fileSystem(path: URL, message: String) + case jsonError(String) + case unknown + + internal var errorDescription: String { + switch self { + case .message(let string): + return string + case .generic(let string, let error): + if let error { + let formattedError = LoggerFormatters.extractMessage(from: error) + + return "\(string) Error: \(formattedError)" + } + + return string + case .notInitialized: + return "Parra has not been initialized. Call Parra.initialize() in applicationDidFinishLaunchingWithOptions." + case .missingAuthentication: + return "An authentication provider has not been set. Add Parra.initialize() to your applicationDidFinishLaunchingWithOptions method." + case .authenticationFailed: + return "Invoking the authentication provider passed to Parra.initialize() failed." + case .networkError: + return "A network error occurred." + case .jsonError(let string): + return "JSON error occurred. Error: \(string)" + case .fileSystem: + return "A file system error occurred." + case .unknown: + return "An unknown error occurred." + } + } + + /// A simple error description string for cases where we don't have more complete control + /// over the output formatting. Interally, we want to always use `Parra/ParraErrorWithExtra` + /// in combination with the `ParraError/errorDescription` and ``ParraError/description`` + /// fields instead of this. + public var description: String { + let baseMessage = errorDescription + + switch self { + case .message, .unknown, .jsonError, .notInitialized, .missingAuthentication: + return baseMessage + case .generic(_, let error): + if let error { + return "\(baseMessage) Error: \(error)" + } + + return baseMessage + case .authenticationFailed(let error): + return "\(baseMessage) Error: \(error)" + case .networkError(let request, let response, let data): +#if DEBUG + let dataString = String(data: data, encoding: .utf8) ?? "unknown" + return "\(baseMessage)\nRequest: \(request)\nResponse: \(response)\nData: \(dataString)" +#else + return "\(baseMessage)\nRequest: \(request)\nResponse: \(response)\nData: \(data.count) byte(s)" +#endif + case .fileSystem(let path, let message): + return "\(baseMessage)\nPath: \(path.relativeString)\nError: \(message)" + } + } +} + +extension ParraError: ParraSanitizedDictionaryConvertible { + var sanitized: ParraSanitizedDictionary { + switch self { + case .generic(_, let error): + if let error { + return [ + "error_description": error.localizedDescription + ] + } + + return [:] + case .authenticationFailed(let error): + return [ + "authentication_error": error + ] + case .networkError(let request, let response, let body): + var bodyInfo: [String : Any] = [ + "length": body.count + ] + +#if DEBUG + let dataString = String( + data: body, + encoding: .utf8 + ) ?? "unknown" + + bodyInfo["data"] = dataString +#endif + + return [ + "request": request.sanitized.dictionary, + "response": response.sanitized.dictionary, + "body": bodyInfo + ] + case .fileSystem(let path, let message): + return [ + "path": path.relativeString, + "error_description": message + ] + case .notInitialized, .missingAuthentication, .message, .jsonError, .unknown: + return [:] + } + } +} diff --git a/Parra/Types/ParraErrorWithExtra.swift b/Parra/Types/ParraErrorWithExtra.swift new file mode 100644 index 000000000..aaa0fd6a1 --- /dev/null +++ b/Parra/Types/ParraErrorWithExtra.swift @@ -0,0 +1,36 @@ +// +// ParraErrorWithExtra.swift +// Parra +// +// Created by Mick MacCallum on 8/5/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal struct ParraErrorWithExtra { + let message: String + let extra: [String : Any]? + + internal init( + message: String, + extra: [String : Any]? + ) { + self.message = message + self.extra = extra + } + + internal init(parraError: ParraError) { + self.message = parraError.errorDescription + self.extra = parraError.sanitized.dictionary + } + + internal init(error: Error) { + let (message, extra) = LoggerFormatters.extractMessage( + from: error + ) + + self.message = message + self.extra = extra + } +} diff --git a/Parra/Types/ParraNotificationCenter.swift b/Parra/Types/ParraNotificationCenter.swift index 9cec4c040..57cc0e28c 100644 --- a/Parra/Types/ParraNotificationCenter.swift +++ b/Parra/Types/ParraNotificationCenter.swift @@ -8,11 +8,14 @@ import Foundation -public class ParraNotificationCenter: NotificationCenterType { - public static let `default` = ParraNotificationCenter() - private let underlyingNotificationCenter = NotificationCenter.default +fileprivate let logger = Logger(category: "Parra notification center") - public func post( +internal class ParraNotificationCenter: NotificationCenterType { + internal let underlyingNotificationCenter = NotificationCenter() + + init() {} + + internal func post( name aName: NSNotification.Name, object anObject: Any? = nil, userInfo aUserInfo: [AnyHashable : Any]? = nil @@ -26,12 +29,12 @@ public class ParraNotificationCenter: NotificationCenterType { } } - public func postAsync( + internal func postAsync( name aName: NSNotification.Name, object anObject: Any? = nil, userInfo aUserInfo: [AnyHashable : Any]? = nil ) async { - parraLogTrace("Posting notification: \(aName.rawValue)") + logger.trace("Posting notification: \(aName.rawValue)") await MainActor.run { DispatchQueue.main.async { @@ -44,7 +47,7 @@ public class ParraNotificationCenter: NotificationCenterType { } } - public func addObserver( + internal func addObserver( _ observer: Any, selector aSelector: Selector, name aName: NSNotification.Name?, @@ -58,7 +61,7 @@ public class ParraNotificationCenter: NotificationCenterType { ) } - public func addObserver( + internal func addObserver( forName name: NSNotification.Name?, object obj: Any?, queue: OperationQueue?, @@ -71,4 +74,16 @@ public class ParraNotificationCenter: NotificationCenterType { using: block ) } + + internal func removeObserver( + _ observer: Any, + name aName: NSNotification.Name?, + object anObject: Any? + ) { + underlyingNotificationCenter.removeObserver( + observer, + name: aName, + object: anObject + ) + } } diff --git a/Parra/Types/ParraSessionsResponse.swift b/Parra/Types/ParraSessionsResponse.swift index 9154cdf4d..37ac37ab1 100644 --- a/Parra/Types/ParraSessionsResponse.swift +++ b/Parra/Types/ParraSessionsResponse.swift @@ -8,9 +8,9 @@ import Foundation -public struct ParraSessionsResponse: Codable { - public let shouldPoll: Bool +internal struct ParraSessionsResponse: Codable { + internal let shouldPoll: Bool /// ms - public let retryDelay: Int - public let retryTimes: Int + internal let retryDelay: Int + internal let retryTimes: Int } diff --git a/Parra/Types/Sessions/ParraSession.swift b/Parra/Types/Sessions/ParraSession.swift deleted file mode 100644 index 0310ee194..000000000 --- a/Parra/Types/Sessions/ParraSession.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// ParraSession.swift -// Parra -// -// Created by Mick MacCallum on 12/29/22. -// Copyright © 2022 Parra, Inc. All rights reserved. -// - -import Foundation - -public struct ParraSessionUpload: Encodable { - let session: ParraSession - - public enum CodingKeys: String, CodingKey { - case events - case userProperties - case startedAt - case endedAt - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(self.session.events, forKey: .events) - try container.encode(self.session.userProperties, forKey: .userProperties) - try container.encode(self.session.createdAt, forKey: .startedAt) - try container.encode(self.session.endedAt, forKey: .endedAt) - } -} - -public struct ParraSession: Codable { - public let sessionId: String - public let createdAt: Date - public private(set) var updatedAt: Date - public private(set) var endedAt: Date? - - public private(set) var events: [ParraSessionEvent] - public private(set) var userProperties: [String: AnyCodable] - public private(set) var hasNewData: Bool - - internal init() { - let now = Date() - - self.sessionId = UUID().uuidString - self.createdAt = now - self.updatedAt = now - self.endedAt = nil - self.events = [] - self.userProperties = [:] - self.hasNewData = false - } - - internal mutating func addEvent(_ event: ParraSessionEvent) { - events.append(event) - - hasNewData = true - updatedAt = Date() - } - - internal mutating func updateUserProperties(_ newProperties: [String: AnyCodable]) { - userProperties = newProperties - - hasNewData = true - updatedAt = Date() - } - - internal mutating func resetSentData() { - hasNewData = false - updatedAt = Date() - } - - internal mutating func end() { - self.hasNewData = true - self.endedAt = Date() - } -} diff --git a/Parra/Types/Sessions/ParraSessionEvent.swift b/Parra/Types/Sessions/ParraSessionEvent.swift deleted file mode 100644 index 370abfc51..000000000 --- a/Parra/Types/Sessions/ParraSessionEvent.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// ParraSessionEvent.swift -// Parra -// -// Created by Mick MacCallum on 12/29/22. -// Copyright © 2022 Parra, Inc. All rights reserved. -// - -import Foundation - -public struct ParraSessionEvent: Codable { - /// The type of the event. Event properties should be consistent for each event name - public let name: String - - /// The date the event occurred - public let createdAt: Date - - public let metadata: [String: AnyCodable] -} diff --git a/Parra/Types/Sessions/ParraSessionEventType.swift b/Parra/Types/Sessions/ParraSessionEventType.swift deleted file mode 100644 index 86bef8020..000000000 --- a/Parra/Types/Sessions/ParraSessionEventType.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// ParraSessionEventType.swift -// Parra -// -// Created by Mick MacCallum on 12/29/22. -// Copyright © 2022 Parra, Inc. All rights reserved. -// - -import Foundation -import UIKit - -public protocol ParraSessionNamedEvent { - var eventName: String { get } -} - -internal enum ParraSessionEventType: ParraSessionNamedEvent { - private enum Constant { - // TODO: Should this be like parra:com.actualapp for events from outside the sdk? - static let corePrefix = "parra" - } - - // Publicly accessible events - case action(source: String, module: ParraModule.Type) - case impression(location: String, module: ParraModule.Type) - - public enum _Internal: ParraSessionNamedEvent { - case log - case appState(state: UIApplication.State) - - public var eventName: String { - switch self { - case .log: - return "\(Constant.corePrefix):log" - case .appState(let state): - return "\(Constant.corePrefix):appstate:\(state.description)" - } - } - } - - public var eventName: String { - switch self { - case .action(let source, let module): - return "\(module.eventPrefix()):\(source):action" - case .impression(let location, let module): - return "\(module.eventPrefix()):\(location):viewed" - } - } -} diff --git a/Parra/Views/Fixtures/ParraFixture.swift b/Parra/Views/Fixtures/ParraFixture.swift new file mode 100644 index 000000000..afa14ca01 --- /dev/null +++ b/Parra/Views/Fixtures/ParraFixture.swift @@ -0,0 +1,54 @@ +// +// ParraFixture.swift +// Parra +// +// Created by Mick MacCallum on 1/18/24. +// Copyright © 2024 Parra, Inc. All rights reserved. +// + +import Foundation +import SwiftUI + +/// Model objects should conform to this type and implement its methods to define high quality fixtures +/// which can be used in the context of multiple SwiftUI views. +internal protocol ParraFixture { + static func validStates() -> [Self] + static func invalidStates() -> [Self] +} + +extension ParraFixture { + @ViewBuilder + static func renderValidStates( + with handler: @escaping (Self) -> some View + ) -> some View { + renderStates( + states: validStates(), + with: handler + ) + } + + @ViewBuilder + static func renderInvalidStates( + with handler: @escaping (Self) -> some View + ) -> some View { + renderStates( + states: invalidStates(), + with: handler + ) + } + + @ViewBuilder + static func renderStates( + states: [Self], + with handler: @escaping (Self) -> some View + ) -> some View { + VStack { + // Iterate by index and lookup elements. Saves requirement for + // conforming types to also implement Identifiable. + ForEach(states.indices, id: \.self) { index in + handler(states[index]) + } + } + .padding() + } +} diff --git a/Parra/Views/Logo/ParraLogo.swift b/Parra/Views/Logo/ParraLogo.swift new file mode 100644 index 000000000..a6e3d9ac6 --- /dev/null +++ b/Parra/Views/Logo/ParraLogo.swift @@ -0,0 +1,113 @@ +// +// ParraLogo.swift +// Parra +// +// Created by Mick MacCallum on 1/19/24. +// Copyright © 2024 Parra, Inc. All rights reserved. +// + +import SwiftUI +import UIKit + +struct ParraLogo: View { + var type: ParraLogoType + + private var iconSize: Double { + return switch type { + case .logo: + 132 + case .logoAndText: + 132 + case .poweredBy: + 16 + } + } + + private var textSize: CGSize { + return switch type { + case .logo: + .zero + case .logoAndText: + CGSize(width: 318, height: 80) + case .poweredBy: + CGSize(width: 98, height: 12) + } + } + + private var elementSpacing: Double { + return switch type { + case .logo: + 0 + case .logoAndText: + 24 + case .poweredBy: + 6 + } + } + + @ViewBuilder + var logoRight: some View { + switch type { + case .logo: + EmptyView() + case .logoAndText, .poweredBy: + Image(uiImage: getLogoImage(of: type)!) + .resizable() + .frame(width: textSize.width, height: textSize.height) + } + } + + @ViewBuilder + var logo: some View { + HStack(alignment: .center, spacing: elementSpacing) { + Image(uiImage: getLogoImage(of: .logo)!) + .resizable() + .frame(width: iconSize, height: iconSize) + + logoRight + } + } + + var body: some View { + logo + } + + private func getLogoImage(of type: ParraLogoType) -> UIImage? { + let name = switch type { + case .logo: + "ParraLogo" + case .logoAndText: + "ParraName" + case .poweredBy: + "PoweredByParra" + } + + return UIImage( + named: name, + in: Parra.bundle(), + with: nil + ) + } +} + +#Preview("Parra Logos", traits: .sizeThatFitsLayout) { + VStack(alignment: .center, spacing: 100) { + VStack(alignment: .center, spacing: 40) { + ParraLogo(type: .logo) + ParraLogo(type: .logoAndText) + ParraLogo(type: .poweredBy) + } + .padding(30) + .environment(\.colorScheme, .light) + .background(.white) + + VStack(alignment: .center, spacing: 40) { + ParraLogo(type: .logo) + ParraLogo(type: .logoAndText) + ParraLogo(type: .poweredBy) + } + .padding(30) + .environment(\.colorScheme, .dark) + .background(.black) + } +} diff --git a/Parra/Views/Logo/ParraLogoButton.swift b/Parra/Views/Logo/ParraLogoButton.swift new file mode 100644 index 000000000..b04439abf --- /dev/null +++ b/Parra/Views/Logo/ParraLogoButton.swift @@ -0,0 +1,27 @@ +// +// ParraLogoButton.swift +// Parra +// +// Created by Mick MacCallum on 1/20/24. +// Copyright © 2024 Parra, Inc. All rights reserved. +// + +import SwiftUI + +struct ParraLogoButton: View { + var type: ParraLogoType + + var body: some View { + Button { + Parra.logEvent(.tap(element: "powered-by-parra")) + + UIApplication.shared.open(Parra.Constants.parraWebRoot) + } label: { + ParraLogo(type: type) + } + } +} + +#Preview { + ParraLogoButton(type: .poweredBy) +} diff --git a/Parra/Views/Logo/ParraLogoType.swift b/Parra/Views/Logo/ParraLogoType.swift new file mode 100644 index 000000000..b1efc229a --- /dev/null +++ b/Parra/Views/Logo/ParraLogoType.swift @@ -0,0 +1,15 @@ +// +// ParraLogoType.swift +// Parra +// +// Created by Mick MacCallum on 1/19/24. +// Copyright © 2024 Parra, Inc. All rights reserved. +// + +import Foundation + +internal enum ParraLogoType { + case logo + case logoAndText + case poweredBy +} diff --git a/ParraTests/Data/TestData.swift b/ParraTests/Data/TestData.swift new file mode 100644 index 000000000..32c04d366 --- /dev/null +++ b/ParraTests/Data/TestData.swift @@ -0,0 +1,383 @@ +// +// TestData.swift +// ParraTests +// +// Created by Mick MacCallum on 7/3/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation +@testable import Parra + +struct TestData { + // MARK: - Cards + struct Cards { + static let choiceCard = ParraCardItem( + id: "id", + campaignId: "", + campaignActionId: "", + questionId: "qid", + type: .question, + displayType: .drawer, + version: "1", + data: .question( + Question( + id: "1", + createdAt: .now, + updatedAt: .now, + deletedAt: nil, + tenantId: "24234234", + title: "Sample question 1", + subtitle: nil, + kind: .radio, + data: .choiceQuestionBody( + ChoiceQuestionBody( + options: [ + ChoiceQuestionOption( + title: "option 1", + value: "", + isOther: nil, + id: "op1" + ), + ChoiceQuestionOption( + title: "option 2", + value: "", + isOther: nil, + id: "op2" + ) + ] + ) + ), + active: true, + expiresAt: Date().addingTimeInterval(100000).ISO8601Format(), + answerQuota: nil, + answer: nil + ) + ) + ) + + static let checkboxCard = ParraCardItem( + id: "id", + campaignId: "", + campaignActionId: "", + questionId: "qid", + type: .question, + displayType: .inline, + version: "1", + data: .question( + Question( + id: "2", + createdAt: .now, + updatedAt: .now, + deletedAt: nil, + tenantId: "24234234", + title: "Sample question 2", + subtitle: "this one has a subtitle", + kind: .checkbox, + data: .checkboxQuestionBody( + CheckboxQuestionBody( + options: [ + CheckboxQuestionOption( + title: "option 1", + value: "", + isOther: nil, + id: "op1" + ), + CheckboxQuestionOption( + title: "option 2", + value: "", + isOther: nil, + id: "op2" + ) + ] + ) + ), + active: false, + expiresAt: nil, + answerQuota: nil, + answer: nil + ) + ) + ) + + static let boolCard = ParraCardItem( + id: "id", + campaignId: "", + campaignActionId: "", + questionId: "qid", + type: .question, + displayType: .popup, + version: "1", + data: .question( + Question( + id: "3", + createdAt: .now, + updatedAt: .now, + deletedAt: nil, + tenantId: "24234234", + title: "Sample question 2", + subtitle: "this one has a subtitle", + kind: .boolean, + data: .booleanQuestionBody( + BooleanQuestionBody(options: [ + BooleanQuestionOption(title: "title", value: "value", id: "id"), + BooleanQuestionOption(title: "title", value: "value", id: "id"), + ]) + ), + active: false, + expiresAt: nil, + answerQuota: nil, + answer: nil + ) + ) + ) + + static let starCard = ParraCardItem( + id: "id", + campaignId: "", + campaignActionId: "", + questionId: "qid", + type: .question, + displayType: .inline, + version: "1", + data: .question( + Question( + id: "4", + createdAt: .now, + updatedAt: .now, + deletedAt: nil, + tenantId: "24234234", + title: "Sample question 2", + subtitle: "this one has a subtitle", + kind: .star, + data: .starQuestionBody( + StarQuestionBody( + starCount: 5, + leadingLabel: "leading", + centerLabel: "center", + trailingLabel: "trailing" + ) + ), + active: false, + expiresAt: nil, + answerQuota: nil, + answer: nil + ) + ) + ) + + static let imageCard = ParraCardItem( + id: "id", + campaignId: "", + campaignActionId: "", + questionId: "qid", + type: .question, + displayType: .drawer, + version: "1", + data: .question( + Question( + id: "5", + createdAt: .now, + updatedAt: .now, + deletedAt: nil, + tenantId: "24234234", + title: "Sample question 2", + subtitle: "this one has a subtitle", + kind: .image, + data: .imageQuestionBody( + ImageQuestionBody( + options: [ + ImageQuestionOption( + title: "title", + value: "val", + id: "id", + asset: Asset(id: "id", url: URL(string: "parra.io/image.png")!) + ), + ImageQuestionOption( + title: "title2", + value: "val2", + id: "id2", + asset: Asset(id: "id2222", url: URL(string: "parra.io/image2.png")!) + ) + ] + ) + ), + active: false, + expiresAt: nil, + answerQuota: nil, + answer: nil + ) + ) + ) + + static let ratingCard = ParraCardItem( + id: "id", + campaignId: "", + campaignActionId: "", + questionId: "qid", + type: .question, + displayType: .popup, + version: "1", + data: .question( + Question( + id: "6", + createdAt: .now, + updatedAt: .now, + deletedAt: nil, + tenantId: "24234234", + title: "Sample question 2", + subtitle: "this one has a subtitle", + kind: .rating, + data: .ratingQuestionBody( + RatingQuestionBody( + options: [ + RatingQuestionOption(title: "title1", value: 1, id: "1"), + RatingQuestionOption(title: "title2", value: 2, id: "2"), + RatingQuestionOption(title: "title3", value: 3, id: "3"), + RatingQuestionOption(title: "title4", value: 4, id: "4"), + RatingQuestionOption(title: "title5", value: 5, id: "5"), + ], + leadingLabel: "leading", + centerLabel: "center", + trailingLabel: "trailing" + ) + ), + active: false, + expiresAt: nil, + answerQuota: nil, + answer: nil + ) + ) + ) + + static let shortTextCard = ParraCardItem( + id: "id", + campaignId: "", + campaignActionId: "", + questionId: "qid", + type: .question, + displayType: .inline, + version: "1", + data: .question( + Question( + id: "7", + createdAt: .now, + updatedAt: .now, + deletedAt: nil, + tenantId: "24234234", + title: "Sample question 2", + subtitle: "this one has a subtitle", + kind: .textShort, + data: .shortTextQuestionBody( + ShortTextQuestionBody(placeholder: "placeholder", minLength: 50) + ), + active: false, + expiresAt: nil, + answerQuota: nil, + answer: nil + ) + ) + ) + + static let longTextCard = ParraCardItem( + id: "id", + campaignId: "", + campaignActionId: "", + questionId: "qid", + type: .question, + displayType: .popup, + version: "1", + data: .question( + Question( + id: "7", + createdAt: .now, + updatedAt: .now, + deletedAt: nil, + tenantId: "24234234", + title: "Sample question 2", + subtitle: "this one has a subtitle", + kind: .textLong, + data: .longTextQuestionBody( + LongTextQuestionBody(placeholder: "placeholder", minLength: 1, maxLength: 1000) + ), + active: false, + expiresAt: nil, + answerQuota: nil, + answer: nil + ) + ) + ) + + static let cardsResponse = CardsResponse( + items: [ + choiceCard, + checkboxCard, + boolCard, + starCard, + imageCard, + ratingCard, + shortTextCard, + longTextCard + ] + ) + } + + // MARK: - Forms + struct Forms { + static func formResponse( + formId: String = UUID().uuidString + ) -> ParraFeedbackFormResponse { + return ParraFeedbackFormResponse( + id: formId, + createdAt: .now, + updatedAt: .now, + deletedAt: nil, + data: FeedbackFormData( + title: "Feedback form", + description: "some description", + fields: [ + FeedbackFormField( + name: "field 1", + title: "Field 1", + helperText: "fill this out", + type: .text, + required: true, + data: .feedbackFormTextFieldData( + FeedbackFormTextFieldData( + placeholder: "placeholder", + lines: 4, + maxLines: 69, + minCharacters: 20, + maxCharacters: 420, + maxHeight: 200 + ) + ) + ) + ] + ) + ) + } + } + + // MARK: - Sessions + struct Sessions { + static let successResponse = ParraSessionsResponse( + shouldPoll: false, + retryDelay: 0, + retryTimes: 0 + ) + + static let pollResponse = ParraSessionsResponse( + shouldPoll: true, + retryDelay: 15, + retryTimes: 5 + ) + } + + // MARK: - Auth + struct Auth { + static let successResponse = ParraCredential( + token: UUID().uuidString + ) + } +} diff --git a/ParraTests/Extensions/Data.swift b/ParraTests/Extensions/Data.swift new file mode 100644 index 000000000..4f994b173 --- /dev/null +++ b/ParraTests/Extensions/Data.swift @@ -0,0 +1,16 @@ +// +// Data.swift +// ParraTests +// +// Created by Mick MacCallum on 7/3/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation +@testable import Parra + +extension Data { + static var emptyJsonObject: Data { + return EmptyJsonObjectData + } +} diff --git a/ParraTests/Extensions/FileManagerTests.swift b/ParraTests/Extensions/FileManagerTests.swift index 42a801805..267dab081 100644 --- a/ParraTests/Extensions/FileManagerTests.swift +++ b/ParraTests/Extensions/FileManagerTests.swift @@ -8,21 +8,23 @@ import XCTest @testable import Parra -let testPath = ParraDataManager.Base.applicationSupportDirectory.safeAppendDirectory("testDir") - -class FileManagerTests: XCTestCase { +class FileManagerTests: MockedParraTestCase { let fileManager = FileManager.default - override func setUpWithError() throws { - try deleteDirectoriesInApplicationSupport() + override func setUp() async throws { + // Specifically not calling super to avoid Parra mocks from being created. + + try fileManager.safeCreateDirectory(at: baseStorageDirectory) } - + func testSafeCreateWhenDirectoryDoesNotExist() throws { - try fileManager.safeCreateDirectory(at: testPath) - + let dirPath = baseStorageDirectory.appendDirectory("testDir") + + try fileManager.safeCreateDirectory(at: dirPath) + var isDirectory: ObjCBool = false let exists = fileManager.fileExists( - atPath: testPath.path, + atPath: dirPath.path, isDirectory: &isDirectory ) @@ -31,16 +33,18 @@ class FileManagerTests: XCTestCase { } func testSafeCreateWhenDirectoryExists() throws { + let dirPath = baseStorageDirectory.appendDirectory("testDir") + try fileManager.createDirectory( - at: testPath, + at: dirPath, withIntermediateDirectories: true ) - try fileManager.safeCreateDirectory(at: testPath) - + try fileManager.safeCreateDirectory(at: dirPath) + var isDirectory: ObjCBool = false let exists = fileManager.fileExists( - atPath: testPath.path, + atPath: dirPath.path, isDirectory: &isDirectory ) @@ -49,8 +53,10 @@ class FileManagerTests: XCTestCase { } func testSafeCreateWhenFileExistsAtDirectoryPath() throws { - fileManager.createFile(atPath: testPath.path, contents: nil) - - XCTAssertThrowsError(try fileManager.safeCreateDirectory(at: testPath)) + let filePath = baseStorageDirectory.appendFilename("testFile.txt") + + fileManager.createFile(atPath: filePath.path, contents: nil) + + XCTAssertThrowsError(try fileManager.safeCreateDirectory(at: filePath)) } } diff --git a/ParraTests/Extensions/ParraAuthenticationProviderType.swift b/ParraTests/Extensions/ParraAuthenticationProviderType.swift new file mode 100644 index 000000000..490793d7a --- /dev/null +++ b/ParraTests/Extensions/ParraAuthenticationProviderType.swift @@ -0,0 +1,23 @@ +// +// ParraAuthenticationProviderType.swift +// ParraTests +// +// Created by Mick MacCallum on 7/3/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation +@testable import Parra + +extension ParraAuthenticationProviderType { + static func mockPublicKey(_ mockParra: MockParra) -> ParraAuthenticationProviderType { + return .publicKey( + tenantId: mockParra.tenantId, + applicationId: mockParra.applicationId, + apiKeyId: UUID().uuidString, + userIdProvider: { + return UUID().uuidString + } + ) + } +} diff --git a/ParraTests/Extensions/ParraEndpoint+CaseIterable.swift b/ParraTests/Extensions/ParraEndpoint+CaseIterable.swift new file mode 100644 index 000000000..de966e9f8 --- /dev/null +++ b/ParraTests/Extensions/ParraEndpoint+CaseIterable.swift @@ -0,0 +1,51 @@ +// +// ParraEndpoint.swift +// ParraTests +// +// Created by Mick MacCallum on 7/3/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation +@testable import Parra + +extension ParraEndpoint: CaseIterable { + public static var allCases: [ParraEndpoint] = { + let testCases: [ParraEndpoint] = [ + .getCards, + .getFeedbackForm(formId: ""), + .postAuthentication(tenantId: ""), + .postBulkAnswerQuestions, + .postBulkSubmitSessions(tenantId: ""), + .postPushTokens(tenantId: ""), + .postSubmitFeedbackForm(formId: "") + ] + + // We can't automatically synthesize CaseIterable conformance, so we're + // providing the list manually. We hard code the list then iterate through + // it to ensure compiler errors here if new cases are added. Performance doesn't + // matter since this only runs once, and only when running tests. + + var finalCases = [ParraEndpoint]() + for testCase in testCases { + switch testCase { + case .postAuthentication: + finalCases.append(testCase) + case .getCards: + finalCases.append(testCase) + case .getFeedbackForm: + finalCases.append(testCase) + case .postSubmitFeedbackForm: + finalCases.append(testCase) + case .postBulkAnswerQuestions: + finalCases.append(testCase) + case .postBulkSubmitSessions: + finalCases.append(testCase) + case .postPushTokens: + finalCases.append(testCase) + } + } + + return finalCases + }() +} diff --git a/ParraTests/Extensions/ParraState.swift b/ParraTests/Extensions/ParraState.swift new file mode 100644 index 000000000..ea08aadb2 --- /dev/null +++ b/ParraTests/Extensions/ParraState.swift @@ -0,0 +1,31 @@ +// +// ParraState.swift +// ParraTests +// +// Created by Mick MacCallum on 7/3/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation +@testable import Parra + +extension ParraState { + static let initialized = ParraState(initialized: true) + static let uninitialized = ParraState(initialized: false) +} + +extension ParraConfigState { + static func initialized( + tenantId: String, + applicationId: String + ) -> ParraConfigState { + var config = ParraConfiguration.default + + config.setTenantId(tenantId) + config.setApplicationId(applicationId) + + return ParraConfigState( + currentState: config + ) + } +} diff --git a/ParraTests/Extensions/String.swift b/ParraTests/Extensions/String.swift new file mode 100644 index 000000000..4f3cdfb1c --- /dev/null +++ b/ParraTests/Extensions/String.swift @@ -0,0 +1,15 @@ +// +// String.swift +// ParraTests +// +// Created by Mick MacCallum on 7/3/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +extension String { + static var now: String { + return Date().ISO8601Format() + } +} diff --git a/ParraTests/Extensions/XCTestCase.swift b/ParraTests/Extensions/XCTestCase.swift new file mode 100644 index 000000000..d7ef24069 --- /dev/null +++ b/ParraTests/Extensions/XCTestCase.swift @@ -0,0 +1,46 @@ +// +// XCTestCase.swift +// ParraTests +// +// Created by Mick MacCallum on 7/3/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation +import XCTest +@testable import Parra + +internal extension XCTestCase { + func addJsonAttachment( + value: T, + name: String? = nil, + lifetime: XCTAttachment.Lifetime = .deleteOnSuccess, + userInfo: [AnyHashable: Any] = [:] + ) throws { + let data = try JSONEncoder.parraEncoder.encode(value) + + addJsonAttachment( + data: data, + name: name, + lifetime: lifetime, + userInfo: userInfo + ) + } + + func addJsonAttachment( + data: Data, + name: String? = nil, + lifetime: XCTAttachment.Lifetime = .deleteOnSuccess, + userInfo: [AnyHashable: Any] = [:] + ) { + let jsonAttachment = XCTAttachment( + data: data, + uniformTypeIdentifier: "public.json" + ) + jsonAttachment.name = name + jsonAttachment.lifetime = lifetime + jsonAttachment.userInfo = userInfo + + add(jsonAttachment) + } +} diff --git a/ParraTests/Managers/ParraDataManagerTests.swift b/ParraTests/Managers/ParraDataManagerTests.swift index edafd1172..fab090678 100644 --- a/ParraTests/Managers/ParraDataManagerTests.swift +++ b/ParraTests/Managers/ParraDataManagerTests.swift @@ -8,14 +8,19 @@ import XCTest @testable import Parra -class ParraDataManagerTests: XCTestCase { +@MainActor +class ParraDataManagerTests: MockedParraTestCase { var parraDataManager: ParraDataManager! - override func setUpWithError() throws { - parraDataManager = ParraDataManager() + override func setUp() async throws { + try createBaseDirectory() + + parraDataManager = createMockDataManager() } - override func tearDownWithError() throws { + override func tearDown() async throws { + deleteBaseDirectory() + parraDataManager = nil } diff --git a/ParraTests/Managers/ParraNetworkManagerTests+Mocks.swift b/ParraTests/Managers/ParraNetworkManagerTests+Mocks.swift deleted file mode 100644 index 7e329d337..000000000 --- a/ParraTests/Managers/ParraNetworkManagerTests+Mocks.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// ParraNetworkManagerTests+Mocks.swift -// ParraTests -// -// Created by Mick MacCallum on 4/2/22. -// - -import Foundation -@testable import Parra - -protocol URLSessionDataTaskType { - func resume() -} - -extension URLSessionDataTask: URLSessionDataTaskType {} - -class MockURLSessionDataTask: URLSessionDataTask { - let request: URLRequest - let dataTaskResolver: (_ request: URLRequest) -> (data: Data?, response: URLResponse?, error: Error?) - let handler: (Data?, URLResponse?, Error?) -> Void - - required init(request: URLRequest, - dataTaskResolver: @escaping (_ request: URLRequest) -> (data: Data?, response: URLResponse?, error: Error?), - handler: @escaping (Data?, URLResponse?, Error?) -> Void) { - self.request = request - self.dataTaskResolver = dataTaskResolver - self.handler = handler - } - - override func resume() { - Task { - let (data, response, error) = dataTaskResolver(request) - - try await Task.sleep(nanoseconds: 1_000_000_000) - - handler(data, response, error) - } - } -} - -class MockURLSession: URLSessionType { - var configuration: URLSessionConfiguration = .default - - func dataForRequest(for request: URLRequest, delegate: URLSessionTaskDelegate?) async throws -> (Data, URLResponse) { - return try await data(for: request, delegate: delegate) - } - - let dataTaskResolver: (_ request: URLRequest) -> (data: Data?, response: URLResponse?, error: Error?) - - required init(dataTaskResolver: @escaping (_ request: URLRequest) -> (data: Data?, response: URLResponse?, error: Error?)) { - self.dataTaskResolver = dataTaskResolver - } - - func data(for request: URLRequest, delegate: URLSessionTaskDelegate?) async throws -> (Data, URLResponse) { - let (data, response, error) = dataTaskResolver(request) - - if let error { - throw error - } - - return (data!, response!) - } - - func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask { - - return MockURLSessionDataTask( - request: request, - dataTaskResolver: dataTaskResolver, - handler: completionHandler - ) - } -} diff --git a/ParraTests/Managers/ParraNetworkManagerTests.swift b/ParraTests/Managers/ParraNetworkManagerTests.swift index 6137fbb0b..69ddd186d 100644 --- a/ParraTests/Managers/ParraNetworkManagerTests.swift +++ b/ParraTests/Managers/ParraNetworkManagerTests.swift @@ -8,34 +8,38 @@ import XCTest @testable import Parra -let fakeModule = FakeModule() - @MainActor -class ParraNetworkManagerTests: XCTestCase { +class ParraNetworkManagerTests: MockedParraTestCase { + private var mockNetworkManager: MockParraNetworkManager! + override func setUp() async throws { - await ParraGlobalState.shared.registerModule(module: fakeModule) + try createBaseDirectory() - await Parra.initialize(authProvider: .default(tenantId: "tenant", applicationId: "myapp", authProvider: { + mockNetworkManager = await createMockNetworkManager { return UUID().uuidString - })) + } } override func tearDown() async throws { - await Parra.deinitialize() - await ParraGlobalState.shared.unregisterModule(module: fakeModule) + mockNetworkManager = nil + + deleteBaseDirectory() } func testAuthenticatedRequestFailsWithoutAuthProvider() async throws { - let networkManager = ParraNetworkManager( - dataManager: MockDataManager(), - urlSession: MockURLSession { request in - return (nil, nil, nil) - } + await mockNetworkManager.networkManager.updateAuthenticationProvider(nil) + + let endpoint = ParraEndpoint.postAuthentication( + tenantId: mockNetworkManager.tenantId ) + let expectation = mockNetworkManager.urlSession.expectInvocation( + of: endpoint + ) + expectation.isInverted = true // Can't expect throw with async func - let response: AuthenticatedRequestResult = await networkManager.performAuthenticatedRequest( - endpoint: .custom(route: "whatever", method: .get) + let response: AuthenticatedRequestResult = await mockNetworkManager.networkManager.performAuthenticatedRequest( + endpoint: endpoint ) switch response.result { @@ -44,53 +48,47 @@ class ParraNetworkManagerTests: XCTestCase { case .failure: break } + + await fulfillment(of: [expectation], timeout: 0.2) } - + func testAuthenticatedRequestInvokesAuthProvider() async throws { - let dataManager = MockDataManager() let token = UUID().uuidString - let route = "whatever" - let networkManager = ParraNetworkManager( - dataManager: dataManager, - urlSession: MockURLSession { request in - return (EmptyJsonObjectData, createTestResponse(route: route), nil) - } + let endpoint = ParraEndpoint.postAuthentication( + tenantId: mockNetworkManager.tenantId + ) + let authEndpointExpectation = mockNetworkManager.urlSession.expectInvocation( + of: endpoint ) - + let authProviderExpectation = XCTestExpectation() - await networkManager.updateAuthenticationProvider { () async throws -> String in + await mockNetworkManager.networkManager.updateAuthenticationProvider { () async throws -> String in authProviderExpectation.fulfill() - + return token } - let _: AuthenticatedRequestResult = await networkManager.performAuthenticatedRequest( - endpoint: .custom(route: route, method: .get) + let _: AuthenticatedRequestResult = await mockNetworkManager.networkManager.performAuthenticatedRequest( + endpoint: endpoint ) - await fulfillment(of: [authProviderExpectation], timeout: 0.1) + await fulfillment( + of: [authProviderExpectation, authEndpointExpectation], + timeout: 0.2 + ) - let persistedCredential = await dataManager.getCurrentCredential() + let persistedCredential = await mockNetworkManager.dataManager.getCurrentCredential() XCTAssertEqual(token, persistedCredential?.token) } - + func testThrowingAuthenticationHandlerFailsRequest() async throws { - let dataManager = MockDataManager() - let route = "whatever" - let networkManager = ParraNetworkManager( - dataManager: dataManager, - urlSession: MockURLSession { request in - return (EmptyJsonObjectData, createTestResponse(route: route), nil) - } - ) - - await networkManager.updateAuthenticationProvider { () async throws -> String in + await mockNetworkManager.networkManager.updateAuthenticationProvider { () async throws -> String in throw URLError(.cannotLoadFromNetwork) } - + // Can't expect throw with async func - let response: AuthenticatedRequestResult = await networkManager.performAuthenticatedRequest( - endpoint: .custom(route: route, method: .get) + let response: AuthenticatedRequestResult = await mockNetworkManager.networkManager.performAuthenticatedRequest( + endpoint: .getCards ) switch response.result { @@ -100,32 +98,23 @@ class ParraNetworkManagerTests: XCTestCase { break } } - + func testDoesNotRefreshCredentialWhenOneIsPresent() async throws { let token = UUID().uuidString let credential = ParraCredential(token: token) - let dataManager = MockDataManager() - - await dataManager.updateCredential(credential: credential) - - let route = "whatever" - let networkManager = ParraNetworkManager( - dataManager: dataManager, - urlSession: MockURLSession { request in - return (EmptyJsonObjectData, createTestResponse(route: route), nil) - } - ) - + + await mockNetworkManager.dataManager.updateCredential(credential: credential) + let authProviderExpectation = XCTestExpectation() authProviderExpectation.isInverted = true - await networkManager.updateAuthenticationProvider { () async throws -> String in + await mockNetworkManager.networkManager.updateAuthenticationProvider { () async throws -> String in authProviderExpectation.fulfill() - + return token } - let response: AuthenticatedRequestResult = await networkManager.performAuthenticatedRequest( - endpoint: .custom(route: route, method: .get) + let response: AuthenticatedRequestResult = await mockNetworkManager.networkManager.performAuthenticatedRequest( + endpoint: .getCards ) switch response.result { @@ -135,168 +124,96 @@ class ParraNetworkManagerTests: XCTestCase { throw error } } - - func testSendsLibraryVersionHeaderForRegisteredModules() async throws { - let dataManager = MockDataManager() - let credential = ParraCredential(token: UUID().uuidString) - - await dataManager.updateCredential(credential: credential) - - let notificationCenter = ParraNotificationCenter.default - - let endpoint = ParraEndpoint.postBulkSubmitSessions(tenantId: "whatever") - let requestHeadersExpectation = XCTestExpectation() - let networkManager = ParraNetworkManager( - dataManager: dataManager, - urlSession: MockURLSession { request in - let matches = (request.allHTTPHeaderFields ?? [:]).keys.contains { headerKey in - return headerKey == "X-PARRA-PLATFORM-SDK-VERSION" - } - - if matches { - requestHeadersExpectation.fulfill() - } - - return (EmptyJsonObjectData, createTestResponse(route: endpoint.route), nil) - } - ) - let sessionManager = ParraSessionManager( - dataManager: dataManager, - networkManager: networkManager - ) + func testSendsLibraryVersionHeader() async throws { + let endpoint = ParraEndpoint.postBulkSubmitSessions(tenantId: mockNetworkManager.tenantId) + let endpointExpectation = mockNetworkManager.urlSession.expectInvocation(of: endpoint) { request in - let syncManager = ParraSyncManager( - networkManager: networkManager, - sessionManager: sessionManager, - notificationCenter: notificationCenter - ) - - Parra.shared = Parra( - dataManager: dataManager, - syncManager: syncManager, - sessionManager: sessionManager, - networkManager: networkManager, - notificationCenter: notificationCenter - ) + return (request.allHTTPHeaderFields ?? [:]).keys.contains { headerKey in + return headerKey == "X-PARRA-PLATFORM-SDK-VERSION" + } + } - let response: AuthenticatedRequestResult = await networkManager.performAuthenticatedRequest( + let response: AuthenticatedRequestResult = await mockNetworkManager.networkManager.performAuthenticatedRequest( endpoint: endpoint ) switch response.result { case .success: - await fulfillment(of: [requestHeadersExpectation], timeout: 0.1) + await fulfillment(of: [endpointExpectation], timeout: 0.1) case .failure(let error): throw error } } - + func testSendsNoBodyWithGetRequests() async throws { - let dataManager = MockDataManager() - let route = "whatever" - let jsonEncoder = JSONEncoder.parraEncoder - - let credential = ParraCredential(token: UUID().uuidString) - await dataManager.updateCredential(credential: credential) - - let requestBodyExpectation = XCTestExpectation() - let networkManager = ParraNetworkManager( - dataManager: dataManager, - urlSession: MockURLSession { request in - if request.httpBody == nil { - requestBodyExpectation.fulfill() - } - - return (EmptyJsonObjectData, createTestResponse(route: route), nil) - }, - jsonEncoder: jsonEncoder - ) + let endpoint = ParraEndpoint.getCards + let endpointExpectation = mockNetworkManager.urlSession.expectInvocation(of: endpoint) { request in + + return request.httpBody == nil + } - let _: AuthenticatedRequestResult = await networkManager.performAuthenticatedRequest( - endpoint: .custom(route: route, method: .get) + let _: AuthenticatedRequestResult = await mockNetworkManager.networkManager.performAuthenticatedRequest( + endpoint: endpoint ) - await fulfillment(of: [requestBodyExpectation], timeout: 0.1) + await fulfillment(of: [endpointExpectation], timeout: 0.1) } func testSendsBodyWithNonGetRequests() async throws { - let dataManager = MockDataManager() - let route = "whatever" - let bodyObject = EmptyRequestObject() - let jsonEncoder = JSONEncoder.parraEncoder - - let credential = ParraCredential(token: UUID().uuidString) - await dataManager.updateCredential(credential: credential) - - let requestBodyExpectation = XCTestExpectation() - let networkManager = ParraNetworkManager( - dataManager: dataManager, - urlSession: MockURLSession { request in - let encoded = try! jsonEncoder.encode(bodyObject) - if request.httpBody == encoded { - requestBodyExpectation.fulfill() - } - - return (EmptyJsonObjectData, createTestResponse(route: route), nil) - }, - jsonEncoder: jsonEncoder + let endpoint = ParraEndpoint.postBulkSubmitSessions( + tenantId: mockNetworkManager.tenantId ) - let _: AuthenticatedRequestResult = await networkManager.performAuthenticatedRequest( - endpoint: .custom(route: route, method: .post) + let endpointExpectation = mockNetworkManager.urlSession.expectInvocation(of: endpoint) { request in + + let encoded = try JSONEncoder.parraEncoder.encode(EmptyRequestObject()) + + return request.httpBody == encoded + } + + let _: AuthenticatedRequestResult = await mockNetworkManager.networkManager.performAuthenticatedRequest( + endpoint: endpoint ) - await fulfillment(of: [requestBodyExpectation], timeout: 0.1) + await fulfillment(of: [endpointExpectation], timeout: 0.1) } func testHandlesNoContentHeader() async throws { - let credential = ParraCredential(token: UUID().uuidString) - let dataManager = MockDataManager() - let jsonEncoder = JSONEncoder.parraEncoder - jsonEncoder.outputFormatting = [] - - await dataManager.updateCredential(credential: credential) - - let route = "whatever" - let networkManager = ParraNetworkManager( - dataManager: dataManager, - urlSession: MockURLSession { request in - return ("{\"key\":\"not empty\"}".data(using: .utf8)!, createTestResponse(route: route, statusCode: 204), nil) - }, - jsonEncoder: jsonEncoder - ) + let endpoint = ParraEndpoint.getCards + let endpointExpectation = mockNetworkManager.urlSession.expectInvocation(of: endpoint, toReturn: { + return (204, "{\"key\":\"not empty\"}".data(using: .utf8)!) + }) - let response: AuthenticatedRequestResult = await networkManager.performAuthenticatedRequest( - endpoint: .custom(route: route, method: .get) + let response: AuthenticatedRequestResult = await mockNetworkManager.networkManager.performAuthenticatedRequest( + endpoint: endpoint ) + await fulfillment(of: [endpointExpectation], timeout: 0.1) + switch response.result { case .success(let data): - XCTAssertEqual(EmptyJsonObjectData, try! jsonEncoder.encode(data)) + let jsonEncoder = JSONEncoder.parraEncoder + jsonEncoder.outputFormatting = [] + + XCTAssertEqual(.emptyJsonObject, try! jsonEncoder.encode(data)) case .failure(let error): throw error } } - + func testClientErrorsFailRequests() async throws { - let dataManager = MockDataManager() - let credential = ParraCredential(token: UUID().uuidString) - await dataManager.updateCredential(credential: credential) - - let route = "whatever" - let networkManager = ParraNetworkManager( - dataManager: dataManager, - urlSession: MockURLSession { request in - return (EmptyJsonObjectData, createTestResponse(route: route, statusCode: 400), nil) - } - ) + let endpoint = ParraEndpoint.getCards + let endpointExpectation = mockNetworkManager.urlSession.expectInvocation(of: endpoint, toReturn: { + return (400, .emptyJsonObject) + }) - // Can't expect throw with async func - let response: AuthenticatedRequestResult = await networkManager.performAuthenticatedRequest( - endpoint: .custom(route: route, method: .get) + let response: AuthenticatedRequestResult = await mockNetworkManager.networkManager.performAuthenticatedRequest( + endpoint: endpoint ) + await fulfillment(of: [endpointExpectation], timeout: 0.1) + switch response.result { case .success: XCTFail() @@ -304,25 +221,19 @@ class ParraNetworkManagerTests: XCTestCase { break } } - + func testServerErrorsFailRequests() async throws { - let dataManager = MockDataManager() - let credential = ParraCredential(token: UUID().uuidString) - await dataManager.updateCredential(credential: credential) - - let route = "whatever" - let networkManager = ParraNetworkManager( - dataManager: dataManager, - urlSession: MockURLSession { request in - return (EmptyJsonObjectData, createTestResponse(route: route, statusCode: 500), nil) - } - ) + let endpoint = ParraEndpoint.getCards + let endpointExpectation = mockNetworkManager.urlSession.expectInvocation(of: endpoint, toReturn: { + return (500, .emptyJsonObject) + }) - // Can't expect throw with async func - let response: AuthenticatedRequestResult = await networkManager.performAuthenticatedRequest( - endpoint: .custom(route: route, method: .get) + let response: AuthenticatedRequestResult = await mockNetworkManager.networkManager.performAuthenticatedRequest( + endpoint: endpoint ) + await fulfillment(of: [endpointExpectation], timeout: 0.1) + switch response.result { case .success: XCTFail() @@ -330,40 +241,35 @@ class ParraNetworkManagerTests: XCTestCase { break } } - + func testReauthenticationSuccess() async throws { var isFirstRequest = true - - let dataManager = MockDataManager() - let token = UUID().uuidString - let credential = ParraCredential(token: UUID().uuidString) - await dataManager.updateCredential(credential: credential) - - let route = "whatever" - let networkManager = ParraNetworkManager( - dataManager: dataManager, - urlSession: MockURLSession { request in - if isFirstRequest { - isFirstRequest = false + let endpoint = ParraEndpoint.getCards + let endpointExpectation = mockNetworkManager.urlSession.expectInvocation( + of: endpoint, + times: 2, + toReturn: { + if isFirstRequest { + isFirstRequest = false + return (401, .emptyJsonObject) + } - return (EmptyJsonObjectData, createTestResponse(route: route, statusCode: 401), nil) - } + return (200, .emptyJsonObject) + }) - return (EmptyJsonObjectData, createTestResponse(route: route, statusCode: 200), nil) - } - ) - let authProviderExpectation = XCTestExpectation() - await networkManager.updateAuthenticationProvider { () async throws -> String in + await mockNetworkManager.networkManager.updateAuthenticationProvider { () async throws -> String in authProviderExpectation.fulfill() - - return token + + return UUID().uuidString } - let response: AuthenticatedRequestResult = await networkManager.performAuthenticatedRequest( - endpoint: .custom(route: route, method: .get) + let response: AuthenticatedRequestResult = await mockNetworkManager.networkManager.performAuthenticatedRequest( + endpoint: endpoint ) + await fulfillment(of: [endpointExpectation, authProviderExpectation], timeout: 0.1) + switch response.result { case .success: break @@ -371,39 +277,14 @@ class ParraNetworkManagerTests: XCTestCase { throw error } } - - func testReauthenticationFailure() async throws { - var isFirstRequest = true - - let dataManager = MockDataManager() - let token = UUID().uuidString - let credential = ParraCredential(token: token) - await dataManager.updateCredential(credential: credential) - - let route = "whatever" - let networkManager = ParraNetworkManager( - dataManager: dataManager, - urlSession: MockURLSession { request in - if isFirstRequest { - isFirstRequest = false - return (EmptyJsonObjectData, createTestResponse(route: route, statusCode: 401), nil) - } - - return (EmptyJsonObjectData, createTestResponse(route: route, statusCode: 400), nil) - } - ) - - let authProviderExpectation = XCTestExpectation() - await networkManager.updateAuthenticationProvider { () async throws -> String in - authProviderExpectation.fulfill() - - return token + func testReauthenticationFailure() async throws { + await mockNetworkManager.networkManager.updateAuthenticationProvider { () async throws -> String in + throw URLError(.networkConnectionLost) } - // Can't expect throw with async func - let response: AuthenticatedRequestResult = await networkManager.performAuthenticatedRequest( - endpoint: .custom(route: route, method: .get) + let response: AuthenticatedRequestResult = await mockNetworkManager.networkManager.performAuthenticatedRequest( + endpoint: .getCards ) switch response.result { @@ -415,52 +296,30 @@ class ParraNetworkManagerTests: XCTestCase { } func testPublicApiKeyAuthentication() async throws { - let tenantId = UUID().uuidString - let applicationId = UUID().uuidString - let apiKeyId = UUID().uuidString - let userId = UUID().uuidString - - let dataManager = MockDataManager() - let notificationCenter = ParraNotificationCenter.default - - let route = "whatever" - let requestExpectation = XCTestExpectation() - let networkManager = ParraNetworkManager( - dataManager: dataManager, - urlSession: MockURLSession { request in - let data = try! JSONEncoder().encode(ParraCredential(token: UUID().uuidString)) - requestExpectation.fulfill() - return (data, createTestResponse(route: route), nil) - } + let endpoint = ParraEndpoint.postAuthentication( + tenantId: mockNetworkManager.tenantId ) + let endpointExpectation = mockNetworkManager.urlSession.expectInvocation( + of: endpoint, + toReturn: { + let data = try JSONEncoder.parraEncoder.encode( + ParraCredential(token: UUID().uuidString) + ) - let sessionManager = ParraSessionManager( - dataManager: dataManager, - networkManager: networkManager - ) + return (200, data) + }) - let syncManager = ParraSyncManager( - networkManager: networkManager, - sessionManager: sessionManager, - notificationCenter: notificationCenter + let _ = try await mockNetworkManager.networkManager.performPublicApiKeyAuthenticationRequest( + forTentant: mockNetworkManager.tenantId, + applicationId: mockNetworkManager.applicationId, + apiKeyId: UUID().uuidString, + userId: UUID().uuidString ) - Parra.shared = Parra( - dataManager: dataManager, - syncManager: syncManager, - sessionManager: sessionManager, - networkManager: networkManager, - notificationCenter: notificationCenter + await fulfillment( + of: [endpointExpectation], + timeout: 0.1 ) - - let _ = try await networkManager.performPublicApiKeyAuthenticationRequest( - forTentant: tenantId, - applicationId: applicationId, - apiKeyId: apiKeyId, - userId: userId - ) - - await fulfillment(of: [requestExpectation], timeout: 0.1) } } diff --git a/ParraTests/Managers/ParraSessionManagerTests.swift b/ParraTests/Managers/ParraSessionManagerTests.swift new file mode 100644 index 000000000..700caf796 --- /dev/null +++ b/ParraTests/Managers/ParraSessionManagerTests.swift @@ -0,0 +1,23 @@ +// +// ParraSessionManagerTests.swift +// ParraTests +// +// Created by Mick MacCallum on 7/3/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import XCTest +@testable import Parra + +@MainActor +final class ParraSessionManagerTests: MockedParraTestCase { + + func testSessionStartsOnInit() async throws { +// let currentSession = mockParra.sessionManager..currentSession +// XCTAssertNotNil(currentSession) + } + + func testSessionsStartInResponseToEvents() async throws { +// mockParra.sessionManager. + } +} diff --git a/ParraTests/Managers/ParraSyncManagerTests.swift b/ParraTests/Managers/ParraSyncManagerTests.swift index 23ea32af1..1f38c715d 100644 --- a/ParraTests/Managers/ParraSyncManagerTests.swift +++ b/ParraTests/Managers/ParraSyncManagerTests.swift @@ -9,89 +9,393 @@ import XCTest @testable import Parra @MainActor -class ParraSyncManagerTests: XCTestCase { - private var sessionManager: ParraSessionManager! - private var syncManager: ParraSyncManager! - - override func setUp() async throws { - let (sessionManager, syncManager) = await configureWithRequestResolver { request in - if let url = request.url?.absoluteString, url.contains("session") { - return ( - try! JSONEncoder.parraEncoder.encode(ParraSessionsResponse(shouldPoll: false, retryDelay: 0, retryTimes: 0)), - createTestResponse(route: "whatever"), - nil - ) - } - return (EmptyJsonObjectData, createTestResponse(route: "whatever"), nil) +class ParraSyncManagerTests: MockedParraTestCase { + + override func tearDown() async throws { + mockParra.syncManager.stopSyncTimer() + + if await mockParra.syncManager.syncState.isSyncing() { + // The test is over. Queued jobs should not be started. + await mockParra.syncManager.cancelEnqueuedSyncs() + + // If there is still a sync in progress when the test ends, wait for it. + let syncDidEndExpectation = mockParra.notificationExpectation( + name: Parra.syncDidEndNotification, + object: mockParra.syncManager + ) + + await fulfillment( + of: [syncDidEndExpectation], + timeout: 5.0 + ) } - self.sessionManager = sessionManager - self.syncManager = syncManager + try await super.tearDown() + } + + func testStartingSyncTimerActivatesTimer() async throws { + mockParra.syncManager.startSyncTimer() - await self.sessionManager.clearSessionHistory() + XCTAssertTrue(mockParra.syncManager.isSyncTimerActive()) } - override func tearDown() async throws { - await sessionManager.resetSession() + func testStartingSyncTimerTriggersSync() async throws { + let syncTimerTickedExpectation = expectation( + description: "Sync started" + ) + mockParra.syncManager.startSyncTimer { + syncTimerTickedExpectation.fulfill() + } + + logEventToSession(named: "test") + + let syncDidBeginExpectation = mockParra.notificationExpectation( + name: Parra.syncDidBeginNotification, + object: mockParra.syncManager + ) - syncManager = nil - sessionManager = nil + await fulfillment( + of: [syncTimerTickedExpectation, syncDidBeginExpectation], + timeout: mockParra.syncManager.syncDelay + 0.5 + ) } - func testEnqueueSync() async throws { - let notificationExpectation = XCTNSNotificationExpectation( + func testStoppingSyncTimerDeactivatesTimer() async throws { + let syncTimerTickedExpectation = expectation( + description: "Sync ticked" + ) + syncTimerTickedExpectation.isInverted = true + mockParra.syncManager.startSyncTimer { + syncTimerTickedExpectation.fulfill() + } + + try await Task.sleep(for: 0.05) + + mockParra.syncManager.stopSyncTimer() + + XCTAssertFalse(mockParra.syncManager.isSyncTimerActive()) + + // Make sure the timer doesn't still end up firing. + await fulfillment( + of: [syncTimerTickedExpectation], + timeout: mockParra.syncManager.syncDelay + ) + } + + func testSyncNotificationsReceivedInCorrectOrder() async throws { + let syncBeginExpectation = mockParra.notificationExpectation( name: Parra.syncDidBeginNotification, - object: syncManager, - notificationCenter: NotificationCenter.default + object: mockParra.syncManager + ) + syncBeginExpectation.assertForOverFulfill = true + syncBeginExpectation.expectedFulfillmentCount = 1 + + let syncEndExpectation = mockParra.notificationExpectation( + name: Parra.syncDidEndNotification, + object: mockParra.syncManager + ) + syncEndExpectation.assertForOverFulfill = true + syncEndExpectation.expectedFulfillmentCount = 1 + + logEventToSession(named: "test") + await mockParra.syncManager.enqueueSync(with: .immediate) + + // Enforces that a begin notification is received before an end notification. + await fulfillment( + of: [syncBeginExpectation, syncEndExpectation], + enforceOrder: true ) + } - await sessionManager.logEvent("test", params: [String: Any]()) - await syncManager.enqueueSync(with: .immediate) + func testEnqueueImmediateSyncNoSyncInProgress() async throws { + let syncBeginExpectation = mockParra.notificationExpectation( + name: Parra.syncDidBeginNotification, + object: mockParra.syncManager + ) - await fulfillment(of: [notificationExpectation], timeout: 1.0) + logEventToSession(named: "test") + await mockParra.syncManager.enqueueSync(with: .immediate) - let isSyncing = await SyncState.shared.isSyncing() + await fulfillment(of: [syncBeginExpectation]) + + let isSyncing = await mockParra.syncManager.syncState.isSyncing() XCTAssertTrue(isSyncing) } - func testEnqueueSyncWithoutEvents() async throws { - let syncDidEnd = XCTNSNotificationExpectation( + func testEnqueueEventualSyncNoSyncInProgress() async throws { + let notificationExpectation = mockParra.notificationExpectation( + name: Parra.syncDidBeginNotification, + object: mockParra.syncManager + ) + + logEventToSession(named: "test") + await mockParra.syncManager.enqueueSync(with: .eventual) + + let isSyncing = await mockParra.syncManager.syncState.isSyncing() + XCTAssertFalse(isSyncing) + + await fulfillment( + of: [notificationExpectation], + timeout: mockParra.syncManager.syncDelay + 0.5 + ) + } + + func testEnqueuingSyncStartsStoppedSyncTimer() async throws { + XCTAssertFalse(mockParra.syncManager.isSyncTimerActive()) + + logEventToSession(named: "test") + await mockParra.syncManager.enqueueSync(with: .eventual) + + XCTAssertTrue(mockParra.syncManager.isSyncTimerActive()) + } + + func testEnqueuingSyncWithoutAuthStopsExistingTimer() async throws { + await mockParra.networkManager.updateAuthenticationProvider(nil) + + let syncTimerTickedExpectation = expectation( + description: "Sync ticked" + ) + syncTimerTickedExpectation.isInverted = true + mockParra.syncManager.startSyncTimer { + syncTimerTickedExpectation.fulfill() + } + + XCTAssertTrue(mockParra.syncManager.isSyncTimerActive()) + + await mockParra.syncManager.enqueueSync(with: .immediate) + + XCTAssertFalse(mockParra.syncManager.isSyncTimerActive()) + + await fulfillment( + of: [syncTimerTickedExpectation], + timeout: mockParra.syncManager.syncDelay + ) + } + + func testSyncEventsIgnoredWithoutAuthProviderSet() async throws { + await mockParra.networkManager.updateAuthenticationProvider(nil) + + let syncDidEnd = mockParra.notificationExpectation( + name: Parra.syncDidEndNotification, + object: mockParra.syncManager + ) + syncDidEnd.isInverted = true + + await mockParra.syncManager.enqueueSync(with: .immediate) + + await fulfillment(of: [syncDidEnd], timeout: 0.1) + } + + func testEnqueueSyncSkippedWithoutEvents() async throws { + let syncDidEnd = mockParra.notificationExpectation( name: Parra.syncDidEndNotification, - object: syncManager, - notificationCenter: NotificationCenter.default + object: mockParra.syncManager ) syncDidEnd.isInverted = true - await syncManager.enqueueSync(with: .immediate) + await mockParra.syncManager.enqueueSync(with: .immediate) - await fulfillment(of: [syncDidEnd], timeout: 1.0) + await fulfillment(of: [syncDidEnd], timeout: 0.1) } - func testEnqueueSyncWhileSyncInProgress() async throws { - let syncDidBegin = XCTNSNotificationExpectation( + func testEnqueueImmediateSyncWhileSyncInProgress() async throws { + // The behavior here is that a sync is already in progress when another + // high priority sync event is started. Meaning that the second sync should + // start as soon as the first one finishes. + + let syncDidBegin = mockParra.notificationExpectation( name: Parra.syncDidBeginNotification, - object: syncManager + object: mockParra.syncManager ) + syncDidBegin.assertForOverFulfill = true + syncDidBegin.expectedFulfillmentCount = 1 + let syncDidEnd = mockParra.notificationExpectation( + name: Parra.syncDidEndNotification, + object: mockParra.syncManager + ) + syncDidEnd.expectedFulfillmentCount = 1 - await Parra.shared.sessionManager.logEvent("test", params: [String: Any]()) - await Parra.shared.syncManager.enqueueSync(with: .immediate) + logEventToSession(named: "test") + await mockParra.syncManager.enqueueSync(with: .immediate) - await fulfillment(of: [syncDidBegin], timeout: 1.0) + await fulfillment(of: [syncDidBegin], timeout: 0.1) - let isSyncing = await SyncState.shared.isSyncing() + let isSyncing = await mockParra.syncManager.syncState.isSyncing() XCTAssertTrue(isSyncing) - let syncDidEnd = XCTNSNotificationExpectation( + await mockParra.syncManager.enqueueSync(with: .immediate) + + let hasEnqueuedSyncJobs = await mockParra.syncManager.enqueuedSyncMode != nil + XCTAssertTrue(hasEnqueuedSyncJobs) + + await fulfillment( + of: [syncDidEnd], + timeout: mockParra.syncManager.syncDelay + ) + + let secondSyncDidBegin = mockParra.notificationExpectation( + name: Parra.syncDidBeginNotification, + object: mockParra.syncManager + ) + secondSyncDidBegin.isInverted = true + + // Make sure a 2nd sync does not start. + await fulfillment( + of: [secondSyncDidBegin], + timeout: 1.5 + ) + + } + + func testEnqueueEventualSyncWhileSyncInProgress() async throws { + // The behavior here is that a sync is already in progress when another + // lower priority sync event is started. Meaning that the second sync should + // be enqueded to take place the next time the sync timer ticks after the + // current sync job finishes. + + // 1. Start the timer + // 2. Trigger an immediate sync + // 3. Observe that the sync ended before the sync timer ticks + // 4. Enqueue an eventual sync. + // 5. Observe that a new sync doesn't begin before the sync timer ticks + // 6. Sync timer ticks, new sync is started. + + let syncTimerTickedExpectation = expectation( + description: "Sync timer ticked" + ) + let syncTimerNotTickedAfterFirstSyncExpectation = expectation( + description: "Sync timer hasn't ticked after first sync complete" + ) + syncTimerNotTickedAfterFirstSyncExpectation.isInverted = true + + let syncTimerNotTickedAfterSecondSyncExpectation = expectation( + description: "Sync timer hasn't ticked after second sync complete" + ) + syncTimerNotTickedAfterSecondSyncExpectation.isInverted = true + + mockParra.syncManager.startSyncTimer { + syncTimerTickedExpectation.fulfill() + syncTimerNotTickedAfterFirstSyncExpectation.fulfill() + syncTimerNotTickedAfterSecondSyncExpectation.fulfill() + } + + let firstSyncDidBegin = mockParra.notificationExpectation( + name: Parra.syncDidBeginNotification, + object: mockParra.syncManager + ) + + logEventToSession(named: "test") + await mockParra.syncManager.enqueueSync(with: .immediate) + + await fulfillment( + of: [firstSyncDidBegin, syncTimerNotTickedAfterFirstSyncExpectation], + timeout: 0.1 + ) + + let secondSyncDidBegin = mockParra.notificationExpectation( + name: Parra.syncDidBeginNotification, + object: mockParra.syncManager + ) + secondSyncDidBegin.isInverted = true + + logEventToSession(named: "test2") + await mockParra.syncManager.enqueueSync(with: .eventual) + + await fulfillment( + of: [secondSyncDidBegin, syncTimerNotTickedAfterSecondSyncExpectation], + timeout: mockParra.syncManager.syncDelay * 0.25 + ) + + let secondSyncDidEnd = mockParra.notificationExpectation( name: Parra.syncDidEndNotification, - object: syncManager + object: mockParra.syncManager + ) + + let thirdSyncDidBegin = mockParra.notificationExpectation( + name: Parra.syncDidBeginNotification, + object: mockParra.syncManager + ) + + await fulfillment( + of: [secondSyncDidEnd, syncTimerTickedExpectation], + timeout: mockParra.syncManager.syncDelay + 2.0 ) - syncDidEnd.expectedFulfillmentCount = 2 - await Parra.shared.syncManager.enqueueSync(with: .immediate) + // At this point, the second sync finished and the sync timer ticked. + // Now we just wait to make sure a 3rd sync job isn't started, since + // all sessions should be cleared. + await fulfillment( + of: [thirdSyncDidBegin], + timeout: mockParra.syncManager.syncDelay + ) + } + + func testCancelEnqueuedSyncs() async throws { + let syncDidBegin = mockParra.notificationExpectation( + name: Parra.syncDidBeginNotification, + object: mockParra.syncManager + ) + + logEventToSession(named: "test") + await mockParra.syncManager.enqueueSync(with: .immediate) + + await fulfillment(of: [syncDidBegin], timeout: 1.0) + + let isSyncing = await mockParra.syncManager.syncState.isSyncing() + XCTAssertTrue(isSyncing) + + await mockParra.syncManager.enqueueSync(with: .immediate) + + let sync2DidBegin = mockParra.notificationExpectation( + name: Parra.syncDidBeginNotification, + object: mockParra.syncManager + ) + sync2DidBegin.isInverted = true - let hasEnqueuedSyncJobs = await Parra.shared.syncManager.enqueuedSyncMode != nil + let hasEnqueuedSyncJobs = await mockParra.syncManager.enqueuedSyncMode != nil XCTAssertTrue(hasEnqueuedSyncJobs) - await fulfillment(of: [syncDidEnd], timeout: 30.0) + await mockParra.syncManager.cancelEnqueuedSyncs() + + let hasEnqueuedSyncJobsAfterCancel = await mockParra.syncManager.enqueuedSyncMode != nil + XCTAssertFalse(hasEnqueuedSyncJobsAfterCancel) + + await fulfillment( + of: [sync2DidBegin], + timeout: mockParra.syncManager.syncDelay + ) + } + + private func logEventToSession( + named name: String, + fileId: String = #fileID, + function: String = #function, + line: Int = #line, + column: Int = #column + ) { + let threadInfo = ParraLoggerThreadInfo( + thread: .current + ) + + let (event, _) = ParraSessionEvent.sessionEventFromEventWrapper( + wrappedEvent: .event( + event: ParraBasicEvent(name: name) + ), + callSiteContext: ParraLoggerCallSiteContext( + fileId: fileId, + function: function, + line: line, + column: column, + threadInfo: threadInfo + ) + ) + + mockParra.dataManager.sessionStorage.writeEvent( + event: event, + context: ParraSessionEventContext( + isClientGenerated: true, + syncPriority: .critical + ) + ) } } diff --git a/ParraTests/Mocks/MockParra.swift b/ParraTests/Mocks/MockParra.swift new file mode 100644 index 000000000..4a73967ff --- /dev/null +++ b/ParraTests/Mocks/MockParra.swift @@ -0,0 +1,44 @@ +// +// MockParra.swift +// ParraTests +// +// Created by Mick MacCallum on 7/3/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation +import XCTest +@testable import Parra + +struct MockParra { + let parra: Parra + let mockNetworkManager: MockParraNetworkManager + + let dataManager: ParraDataManager + let syncManager: ParraSyncManager + let sessionManager: ParraSessionManager + let networkManager: ParraNetworkManager + let notificationCenter: ParraNotificationCenter + + let tenantId: String + let applicationId: String + + func notificationExpectation( + name: Notification.Name, + object: Any? = nil + ) -> XCTNSNotificationExpectation { + return XCTNSNotificationExpectation( + name: name, + object: object, + notificationCenter: notificationCenter.underlyingNotificationCenter + ) + } + + func tearDown() async throws { + mockNetworkManager.urlSession.resetExpectations() + + if await parra.state.isInitialized() { + await parra.state.unregisterModule(module: parra) + } + } +} diff --git a/ParraTests/Mocks/MockedParraTestCase.swift b/ParraTests/Mocks/MockedParraTestCase.swift new file mode 100644 index 000000000..736b3fdf3 --- /dev/null +++ b/ParraTests/Mocks/MockedParraTestCase.swift @@ -0,0 +1,186 @@ +// +// MockedParraTestCase.swift +// ParraTests +// +// Created by Mick MacCallum on 10/3/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import XCTest +@testable import Parra + +@MainActor +class MockedParraTestCase: ParraBaseMock { + internal var mockParra: MockParra! + + override func setUp() async throws { + try await super.setUp() + + mockParra = await createMockParra( + state: .initialized + ) + } + + override func tearDown() async throws { + if let mockParra { + try await mockParra.tearDown() + } + + mockParra = nil + + try await super.tearDown() + } + + internal func createMockParra( + state: ParraState = ParraState(), + tenantId: String = UUID().uuidString, + applicationId: String = UUID().uuidString + ) async -> MockParra { + + var newConfig = ParraConfiguration(options: []) + newConfig.setTenantId(tenantId) + newConfig.setApplicationId(applicationId) + + let mockNetworkManager = await createMockNetworkManager( + state: state, + tenantId: tenantId, + applicationId: applicationId + ) + + let notificationCenter = ParraNotificationCenter() + let syncState = ParraSyncState() + + let configState = ParraConfigState() + await configState.updateState(newConfig) + + let sessionManager = ParraSessionManager( + dataManager: mockNetworkManager.dataManager, + networkManager: mockNetworkManager.networkManager, + loggerOptions: ParraConfigState.defaultState.loggerOptions + ) + + let syncManager = ParraSyncManager( + state: state, + syncState: syncState, + networkManager: mockNetworkManager.networkManager, + sessionManager: sessionManager, + notificationCenter: notificationCenter, + syncDelay: 0.25 + ) + + Logger.loggerBackend = sessionManager + + let parra = Parra( + state: state, + configState: configState, + dataManager: mockNetworkManager.dataManager, + syncManager: syncManager, + sessionManager: sessionManager, + networkManager: mockNetworkManager.networkManager, + notificationCenter: notificationCenter + ) + + // Reset the singleton. This is a bit of a problem because there are some + // places internally within the SDK that may access it, but this will at least + // prevent an uncontroller new instance from being lazily created on first access. + Parra.setSharedInstance(parra: parra) + + if await state.isInitialized() { + await state.registerModule(module: parra) + await sessionManager.initializeSessions() + } + + return MockParra( + parra: parra, + mockNetworkManager: mockNetworkManager, + dataManager: mockNetworkManager.dataManager, + syncManager: syncManager, + sessionManager: sessionManager, + networkManager: mockNetworkManager.networkManager, + notificationCenter: notificationCenter, + tenantId: tenantId, + applicationId: applicationId + ) + } + + func createMockDataManager() -> ParraDataManager { + + let storageDirectoryName = ParraDataManager.Directory.storageDirectoryName + let credentialStorageModule = ParraStorageModule( + dataStorageMedium: .fileSystem( + baseUrl: baseStorageDirectory, + folder: storageDirectoryName, + fileName: ParraDataManager.Key.userCredentialsKey, + storeItemsSeparately: false, + fileManager: .default + ), + jsonEncoder: .parraEncoder, + jsonDecoder: .parraDecoder + ) + + let sessionStorageUrl = baseStorageDirectory + .appendFilename(storageDirectoryName) + .appendFilename("sessions") + + let credentialStorage = CredentialStorage( + storageModule: credentialStorageModule + ) + + let sessionStorage = SessionStorage( + sessionReader: SessionReader( + basePath: sessionStorageUrl, + sessionJsonDecoder: .parraDecoder, + eventJsonDecoder: .spaceOptimizedDecoder, + fileManager: .default + ), + sessionJsonEncoder: .parraEncoder, + eventJsonEncoder: .spaceOptimizedEncoder + ) + + return ParraDataManager( + baseDirectory: baseStorageDirectory, + credentialStorage: credentialStorage, + sessionStorage: sessionStorage + ) + } + + func createMockNetworkManager( + state: ParraState = .initialized, + tenantId: String = UUID().uuidString, + applicationId: String = UUID().uuidString, + authenticationProvider: ParraAuthenticationProviderFunction? = nil + ) async -> MockParraNetworkManager { + let dataManager = createMockDataManager() + + let urlSession = MockURLSession(testCase: self) + let configState = ParraConfigState.initialized( + tenantId: tenantId, + applicationId: applicationId + ) + + let networkManager = ParraNetworkManager( + state: state, + configState: configState, + dataManager: dataManager, + urlSession: urlSession, + jsonEncoder: .parraEncoder, + jsonDecoder: .parraDecoder + ) + + if await state.isInitialized() { + await networkManager.updateAuthenticationProvider( + authenticationProvider ?? { + return UUID().uuidString + } + ) + } + + return MockParraNetworkManager( + networkManager: networkManager, + dataManager: dataManager, + urlSession: urlSession, + tenantId: tenantId, + applicationId: applicationId + ) + } +} diff --git a/ParraTests/Mocks/Network/MockParraNetworkManager.swift b/ParraTests/Mocks/Network/MockParraNetworkManager.swift new file mode 100644 index 000000000..3bc18ff22 --- /dev/null +++ b/ParraTests/Mocks/Network/MockParraNetworkManager.swift @@ -0,0 +1,18 @@ +// +// MockParraNetworkManager.swift +// ParraTests +// +// Created by Mick MacCallum on 7/3/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation +@testable import Parra + +struct MockParraNetworkManager { + let networkManager: ParraNetworkManager + let dataManager: ParraDataManager + let urlSession: MockURLSession + let tenantId: String + let applicationId: String +} diff --git a/ParraTests/Mocks/Network/MockURLSession.swift b/ParraTests/Mocks/Network/MockURLSession.swift new file mode 100644 index 000000000..516610199 --- /dev/null +++ b/ParraTests/Mocks/Network/MockURLSession.swift @@ -0,0 +1,257 @@ +// +// MockURLSession.swift +// ParraTests +// +// Created by Mick MacCallum on 7/3/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation +import XCTest +@testable import Parra + +typealias DataTaskResponse = (data: Data?, response: URLResponse?, error: Error?) +typealias DataTaskResolver = (_ request: URLRequest) -> DataTaskResponse + +internal class MockURLSession: URLSessionType { + var configuration: URLSessionConfiguration = .default + + private let testCase: XCTestCase + + private var expectedEndpoints = [ + String: ( + expectation: XCTestExpectation, + times: Int, + predicate: ((URLRequest) throws -> Bool)?, + returning: (() throws -> (Int, Data))? + ) + ]() + + init(testCase: XCTestCase) { + self.testCase = testCase + } + + func resetExpectations() { + expectedEndpoints.removeAll() + } + + private func resolve(_ request: URLRequest) -> DataTaskResponse { + guard let endpoint = matchingEndpoint(for: request) else { + let route = request.url!.absoluteString + return ( + nil, + createTestResponse( + route: route, + statusCode: 400 + ), + ParraError.generic("URL provided with request did not match any ParraEndpoint case. \(route)", nil) + ) + } + + do { + var responseData = try endpoint.getMockResponseData() + var responseStatus = 200 + // We were able to get response data for this mocked object. Fulfill the + // expectation. Possibly expand this in the future to allow setting expected + // status codes. + + let slug = endpoint.slug + if let endpointExpectation = expectedEndpoints[slug] { + // If there is a predicate function, fulfill if it returns true + // If there isn't a predicate function, fulfill. + let predicateResult = try endpointExpectation.predicate?(request) ?? true + + if predicateResult { + endpointExpectation.expectation.fulfill() + } else { + throw ParraError.generic("Predicate failed to mock of route: \(slug)", nil) + } + + if let response = endpointExpectation.returning { + let (status, data) = try response() + + responseStatus = status + responseData = data + } + + if endpointExpectation.times <= 1 { + expectedEndpoints.removeValue(forKey: slug) + } else { + expectedEndpoints[slug] = ( + endpointExpectation.expectation, + endpointExpectation.times - 1, + endpointExpectation.predicate, + endpointExpectation.returning + ) + } + } + + testCase.addJsonAttachment( + data: responseData, + name: "Response object", + lifetime: .keepAlways + ) + + return ( + responseData, + createTestResponse( + route: endpoint.route, + statusCode: responseStatus + ), + nil + ) + } catch let error { + if let body = request.httpBody { + testCase.addJsonAttachment( + data: body, + name: "Request body" + ) + } + + if let headers = request.allHTTPHeaderFields { + try? testCase.addJsonAttachment( + value: headers, + name: "Request headers" + ) + } + + return ( + nil, + createTestResponse( + route: endpoint.route, + statusCode: 400 + ), + error + ) + } + } + + func dataForRequest( + for request: URLRequest, + delegate: URLSessionTaskDelegate? + ) async throws -> (Data, URLResponse) { + return try await data( + for: request, + delegate: delegate + ) + } + + func data( + for request: URLRequest, + delegate: URLSessionTaskDelegate? + ) async throws -> (Data, URLResponse) { + // Changing this value may break time sensitive tests. + try await Task.sleep(for: 0.1) + + let (data, response, error) = resolve(request) + + if let error { + throw error + } + + return (data!, response!) + } + + func expectInvocation( + of endpoint: ParraEndpoint, + times: Int = 1, + matching predicate: ((URLRequest) throws -> Bool)? = nil, + toReturn responder: (() throws -> (Int, Data))? = nil + ) -> XCTestExpectation { + let expectation = testCase.expectation( + description: "Expected endpoint \(endpoint.slug) to be called" + ) + expectation.assertForOverFulfill = true + expectation.expectedFulfillmentCount = times + + expectedEndpoints[endpoint.slug] = (expectation, times, predicate, responder) + + return expectation + } + + func expectInvocation( + of endpoint: ParraEndpoint, + times: Int = 1, + matching predicate: ((URLRequest) throws -> Bool)? = nil, + toReturn value: (Int, T) + ) throws -> XCTestExpectation { + let (status, data) = value + let encodedData = try JSONEncoder.parraEncoder.encode(data) + + return expectInvocation( + of: endpoint, + times: times, + matching: predicate + ) { + return (status, encodedData) + } + } + + /// Attempts to find a ParraEndpoint case that most closely matches the request url. + /// This is done by iterating over the path components of both the request url, and the + /// expected url components of each endpoint and comparing non-variable components. + private func matchingEndpoint(for request: URLRequest) -> ParraEndpoint? { + guard let url = request.url else { + return nil + } + + let apiRoot = Parra.InternalConstants.parraApiRoot + let urlString = url.absoluteString.trimmingCharacters(in: .punctuationCharacters) + let rootString = apiRoot.absoluteString + + guard urlString.starts(with: rootString) else { + return nil + } + + let suffix = urlString.dropFirst(rootString.count) + let remainingComponents = suffix.split(separator: "/") + + for endpoint in ParraEndpoint.allCases { + let slugComponents = endpoint.slug.split(separator: "/") + + // If they don't have the same path component count, it's an easy no-match. + guard remainingComponents.count == slugComponents.count else { + continue + } + + var endpointMatches = true + for (index, slugComponent) in slugComponents.enumerated() { + // Slug components starting with : are used to denote a variable path + // component and do not need to be compared. + guard !slugComponent.starts(with: ":") else { + continue + } + + let comparisonComponent = remainingComponents[index] + let matches = slugComponent == comparisonComponent + + // Anything component not matching prevents a match. + if !matches { + endpointMatches = false + break + } + } + + if endpointMatches { + return endpoint + } + } + + return nil + } + + private func createTestResponse( + route: String, + statusCode: Int = 200, + additionalHeaders: [String : String] = [:] + ) -> HTTPURLResponse { + let url = Parra.InternalConstants.parraApiRoot.appendingPathComponent(route) + + return HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: [:].merging(additionalHeaders) { (_, new) in new } + )! + } +} diff --git a/ParraTests/Mocks/Network/URLSessionDataTaskType.swift b/ParraTests/Mocks/Network/URLSessionDataTaskType.swift new file mode 100644 index 000000000..c5355d393 --- /dev/null +++ b/ParraTests/Mocks/Network/URLSessionDataTaskType.swift @@ -0,0 +1,15 @@ +// +// URLSessionDataTaskType.swift +// ParraTests +// +// Created by Mick MacCallum on 7/3/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation + +internal protocol URLSessionDataTaskType { + func resume() +} + +extension URLSessionDataTask: URLSessionDataTaskType {} diff --git a/ParraTests/Mocks/ParraBaseMock.swift b/ParraTests/Mocks/ParraBaseMock.swift new file mode 100644 index 000000000..b829e6308 --- /dev/null +++ b/ParraTests/Mocks/ParraBaseMock.swift @@ -0,0 +1,69 @@ +// +// ParraBaseMock.swift +// ParraTests +// +// Created by Mick MacCallum on 10/9/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import XCTest +@testable import Parra + +fileprivate let logger = Logger(bypassEventCreation: true, category: "Parra Mock Base") + +@MainActor +class ParraBaseMock: XCTestCase { + + override func setUp() async throws { + try await super.setUp() + + try createBaseDirectory() + } + + override func tearDown() async throws { + try await super.tearDown() + + // Clean up data created by tests + deleteBaseDirectory() + } + + public var baseStorageDirectory: URL { + return directoryPath(for: testRun) + } + + internal func directoryName(for testRun: XCTestRun) -> String { + return "Testing \(testRun.test.name)" + } + + internal func directoryPath(for testRun: XCTestRun?) -> URL { + let testDirectory: String = if let testRun { + directoryName(for: testRun) + } else { + "test-run-\(UUID().uuidString)" + } + + return ParraDataManager.Base.applicationSupportDirectory + .appendDirectory(testDirectory) + } + + internal func createBaseDirectory() throws { + deleteBaseDirectory() + try FileManager.default.safeCreateDirectory(at: baseStorageDirectory) + } + + internal func deleteBaseDirectory() { + let fileManager = FileManager.default + + do { + if try fileManager.safeDirectoryExists(at: baseStorageDirectory) { + if fileManager.isDeletableFile(atPath: baseStorageDirectory.nonEncodedPath()) { + try fileManager.removeItem(at: baseStorageDirectory) + } else { + logger.warn("File was not deletable!!! \(baseStorageDirectory.nonEncodedPath())") + } + } + } catch let error { + logger.error("Error removing base storage directory", error) + } + } +} diff --git a/ParraTests/Parra+AuthenticationTests.swift b/ParraTests/Parra+AuthenticationTests.swift index c1aa65bb1..2baa63502 100644 --- a/ParraTests/Parra+AuthenticationTests.swift +++ b/ParraTests/Parra+AuthenticationTests.swift @@ -9,103 +9,118 @@ import XCTest @testable import Parra @MainActor -class ParraAuthenticationTests: XCTestCase { - - @MainActor +class ParraAuthenticationTests: MockedParraTestCase { override func setUp() async throws { - await ParraGlobalState.shared.deinitialize() - - await configureWithRequestResolverOnly { request in - return (EmptyJsonObjectData, createTestResponse(route: "whatever"), nil) - } + // Setup without initialization + mockParra = await createMockParra() } - + @MainActor func testInitWithDefaultAuthProvider() async throws { let token = UUID().uuidString - let startAuthProvider = await Parra.shared.networkManager.getAuthenticationProvider() + let startAuthProvider = await mockParra.parra.networkManager.getAuthenticationProvider() XCTAssertNil(startAuthProvider) - await Parra.initialize(authProvider: .default(tenantId: "tenant", applicationId: "myapp", authProvider: { - return token - })) + await mockParra.parra.initialize( + options: [], + authProvider: .default( + tenantId: mockParra.tenantId, + applicationId: mockParra.applicationId, + authProvider: { + return token + } + ) + ) - let endAuthProvider = await Parra.shared.networkManager.getAuthenticationProvider() + let endAuthProvider = await mockParra.parra.networkManager.getAuthenticationProvider() XCTAssertNotNil(endAuthProvider) } - @MainActor func testInitWithPublicKeyAuthProvider() async throws { - let dataManager = ParraDataManager() - let tenantId = UUID().uuidString - let apiKeyId = UUID().uuidString - - let notificationCenter = ParraNotificationCenter.default - - let route = "tenants/\(tenantId)/issuers/public/auth/token" - let networkManager = ParraNetworkManager( - dataManager: dataManager, - urlSession: MockURLSession { request in - let data = try! JSONEncoder().encode(ParraCredential(token: UUID().uuidString)) - return (data, createTestResponse(route: route), nil) - } + let authEndpointExpectation = try mockParra.mockNetworkManager.urlSession.expectInvocation( + of: .postAuthentication(tenantId: mockParra.tenantId), + toReturn: (200, ParraCredential(token: UUID().uuidString)) ) - let sessionManager = ParraSessionManager( - dataManager: dataManager, - networkManager: networkManager + let authProviderExpectation = XCTestExpectation() + authProviderExpectation.expectedFulfillmentCount = 2 + await mockParra.parra.initialize( + authProvider: .publicKey( + tenantId: mockParra.tenantId, + applicationId: mockParra.applicationId, + apiKeyId: UUID().uuidString, + userIdProvider: { + authProviderExpectation.fulfill() + return UUID().uuidString + } + ) ) - let syncManager = ParraSyncManager( - networkManager: networkManager, - sessionManager: sessionManager, - notificationCenter: notificationCenter - ) + let _ = try await mockParra.mockNetworkManager.networkManager.getAuthenticationProvider()!() - Parra.shared = Parra( - dataManager: dataManager, - syncManager: syncManager, - sessionManager: sessionManager, - networkManager: networkManager, - notificationCenter: notificationCenter + await fulfillment( + of: [authEndpointExpectation, authProviderExpectation], + timeout: 2 ) - - let authProviderExpectation = XCTestExpectation() - - await Parra.initialize( - authProvider: .publicKey(tenantId: tenantId, applicationId: "myapp", apiKeyId: apiKeyId, userIdProvider: { - authProviderExpectation.fulfill() - return UUID().uuidString - })) - - let _ = try await Parra.shared.networkManager.getAuthenticationProvider()!() - - await fulfillment(of: [authProviderExpectation], timeout: 0.1) } - - @MainActor + func testInitWithDefaultAuthProviderFailure() async throws { - await Parra.initialize(authProvider: .default(tenantId: "tenant", applicationId: "myapp", authProvider: { - throw URLError(.badServerResponse) - })) + await mockParra.parra.initialize( + authProvider: .default( + tenantId: mockParra.tenantId, + applicationId: mockParra.applicationId, + authProvider: { + throw URLError(.badServerResponse) + } + ) + ) do { - let _ = try await Parra.shared.networkManager.getAuthenticationProvider()!() + let _ = try await mockParra.mockNetworkManager.networkManager.getAuthenticationProvider()!() XCTFail() } catch {} } - @MainActor func testInitWithPublicKeyAuthProviderFailure() async throws { - await Parra.initialize(authProvider: .publicKey(tenantId: "", applicationId: "myapp", apiKeyId: "", userIdProvider: { - throw URLError(.badServerResponse) - })) + await mockParra.parra.initialize( + authProvider: .publicKey( + tenantId: mockParra.tenantId, + applicationId: mockParra.applicationId, + apiKeyId: UUID().uuidString, + userIdProvider: { + throw URLError(.badServerResponse) + } + ) + ) do { - let _ = try await Parra.shared.networkManager.getAuthenticationProvider()!() + let _ = try await mockParra.mockNetworkManager.networkManager.getAuthenticationProvider()!() XCTFail() } catch {} } + + func testMultipleInvocationsDoNotReinitialize() async throws { + await mockParra.parra.initialize(authProvider: .mockPublicKey(mockParra)) + + let isInitialized = await mockParra.parra.state.isInitialized() + XCTAssertTrue(isInitialized) + + await mockParra.parra.initialize( + authProvider: .publicKey( + tenantId: UUID().uuidString, + applicationId: UUID().uuidString, + apiKeyId: UUID().uuidString, + userIdProvider: { + return UUID().uuidString + } + ) + ) + + let configState = await mockParra.parra.configState.getCurrentState() + + XCTAssertEqual(configState.applicationId, mockParra.applicationId) + XCTAssertEqual(configState.tenantId, mockParra.tenantId) + } } diff --git a/ParraTests/Parra+PushTests.swift b/ParraTests/Parra+PushTests.swift index ba6e45358..c25f7349d 100644 --- a/ParraTests/Parra+PushTests.swift +++ b/ParraTests/Parra+PushTests.swift @@ -6,4 +6,98 @@ // Copyright © 2023 Parra, Inc. All rights reserved. // +import XCTest import Foundation +@testable import Parra + + +fileprivate let testBase64TokenString = "gHdLonk9TsQIRKG8Jhagnb1+ehF+g/qXyfPjb2O23qH4PlDAdS+m8crD0UXgH/oQm2ycdmv3Rnjt7HEYrdBM4E0g+a8HDrKFitM2RHBYPVg=" +fileprivate let testToken = Data(base64Encoded: testBase64TokenString)! +fileprivate let testTokenString = testToken.map { String(format: "%02.2hhx", $0) }.joined() + +class ParraPushTests: MockedParraTestCase { + override func setUp() async throws { + try createBaseDirectory() + + // Setup without initialization + mockParra = await createMockParra(state: .uninitialized) + } + + override func tearDown() async throws { + if let mockParra { + try await mockParra.tearDown() + await mockParra.parra.state.deinitialize() + } + + try await super.tearDown() + } + + func testRegisterDataTriggersUpdate() async { + await mockParra.parra.initialize(authProvider: .mockPublicKey(mockParra)) + + let pushUploadExpectation = mockParra.mockNetworkManager.urlSession.expectInvocation( + of: .postPushTokens(tenantId: mockParra.tenantId) + ) + + await mockParra.parra.registerDevicePushToken(testToken) + + await fulfillment(of: [pushUploadExpectation]) + } + + func testCachesPushTokenIfSdkNotInitialized() async throws { + await mockParra.parra.registerDevicePushToken(testToken) + + let cachedToken = await mockParra.parra.state.getCachedTemporaryPushToken() + XCTAssertEqual(cachedToken, testTokenString) + } + + func testRegistrationAfterInitializationUploadsToken() async { + await mockParra.parra.initialize(authProvider: .mockPublicKey(mockParra)) + await mockParra.parra.registerDevicePushToken(testToken) + + let cachedToken = await mockParra.parra.state.getCachedTemporaryPushToken() + XCTAssertNil(cachedToken) + } + + func testRegistrationUploadFailureStoresToken() async { + await mockParra.parra.state.clearTemporaryPushToken() + let cachedTokenBefore = await mockParra.parra.state.getCachedTemporaryPushToken() + XCTAssertNil(cachedTokenBefore) + + let pushUploadExpectation = mockParra.mockNetworkManager.urlSession.expectInvocation( + of: .postPushTokens(tenantId: mockParra.tenantId), + toReturn: { + throw URLError(.networkConnectionLost) + } + ) + + await mockParra.parra.initialize(authProvider: .mockPublicKey(mockParra)) + await mockParra.parra.registerDevicePushToken(testToken) + + await fulfillment(of: [pushUploadExpectation], timeout: 2) + + let cachedTokenAfter = await mockParra.parra.state.getCachedTemporaryPushToken() + XCTAssertNotNil(cachedTokenAfter) + } + + func testInitializationAfterRegistrationClearsToken() async { + await mockParra.parra.registerDevicePushToken(testToken) + + let cachedToken = await mockParra.parra.state.getCachedTemporaryPushToken() + XCTAssertNotNil(cachedToken) + + await mockParra.parra.initialize(authProvider: .mockPublicKey(mockParra)) + + let cachedTokenAfter = await mockParra.parra.state.getCachedTemporaryPushToken() + XCTAssertNil(cachedTokenAfter) + } + + func testAcceptsRegistrationFailure() async throws { + let error = ParraError.generic("made up error", nil) + // no op for now + await mockParra.parra.didFailToRegisterForRemoteNotifications(with: error) + + XCTAssertTrue(true) + } +} + diff --git a/Parra/Parra.xctestplan b/ParraTests/Parra.xctestplan similarity index 78% rename from Parra/Parra.xctestplan rename to ParraTests/Parra.xctestplan index a2101d297..cb29fc292 100644 --- a/Parra/Parra.xctestplan +++ b/ParraTests/Parra.xctestplan @@ -8,9 +8,14 @@ { "key" : "PARRA_LOG_LEVEL", "value" : "trace" + }, + { + "key" : "PARRA_DEBUG_EVENT_LOGGING", + "value" : "1" } ], - "threadSanitizerEnabled" : true + "threadSanitizerEnabled" : false, + "undefinedBehaviorSanitizerEnabled" : false } } ], @@ -26,7 +31,7 @@ }, "testExecutionOrdering" : "random", "testTimeoutsEnabled" : true, - "threadSanitizerEnabled" : true + "threadSanitizerEnabled" : false }, "testTargets" : [ { diff --git a/ParraTests/ParraTests.swift b/ParraTests/ParraTests.swift index ee6df88e6..95f8799f2 100644 --- a/ParraTests/ParraTests.swift +++ b/ParraTests/ParraTests.swift @@ -11,62 +11,74 @@ import XCTest class FakeModule: ParraModule { static var name: String = "FakeModule" - func hasDataToSync() async -> Bool { + func hasDataToSync(since date: Date?) async -> Bool { return true } func synchronizeData() async { - try! await Task.sleep(nanoseconds: 1_000_000_000) + try! await Task.sleep(for: 0.25) } } @MainActor -class ParraTests: XCTestCase { - override func setUp() async throws { - await configureWithRequestResolver { request in - return (EmptyJsonObjectData, createTestResponse(route: "whatever"), nil) - } - } +class ParraTests: MockedParraTestCase { + private var fakeModule: FakeModule? override func tearDown() async throws { - await Parra.deinitialize() - await Parra.shared.sessionManager.resetSession() + if let fakeModule { + await mockParra.parra.state.unregisterModule(module: fakeModule) + } + + try await super.tearDown() } func testModulesCanBeRegistered() async throws { - let module = FakeModule() + fakeModule = FakeModule() - await ParraGlobalState.shared.registerModule(module: module) + await mockParra.parra.state.registerModule(module: fakeModule!) - let hasRegistered = await ParraGlobalState.shared.hasRegisteredModule(module: module) + let hasRegistered = await mockParra.parra.state.hasRegisteredModule( + module: fakeModule! + ) + XCTAssertTrue(hasRegistered) - let modules = await ParraGlobalState.shared.getAllRegisteredModules() - XCTAssert(modules.keys.contains(FakeModule.name)) + + let modules = await mockParra.parra.state.getAllRegisteredModules() + + XCTAssert(modules.contains(where: { testModule in + return type(of: fakeModule!).name == type(of: testModule).name + })) } func testModuleRegistrationIsDeduplicated() async throws { - let module = FakeModule() + fakeModule = FakeModule() - let checkBeforeRegister = await ParraGlobalState.shared.hasRegisteredModule(module: module) + let checkBeforeRegister = await mockParra.parra.state.hasRegisteredModule( + module: fakeModule! + ) XCTAssertFalse(checkBeforeRegister) - let previous = await ParraGlobalState.shared.getAllRegisteredModules() + let previous = await mockParra.parra.state.getAllRegisteredModules() - await ParraGlobalState.shared.registerModule(module: module) - await ParraGlobalState.shared.registerModule(module: module) + await mockParra.parra.state.registerModule(module: fakeModule!) + await mockParra.parra.state.registerModule(module: fakeModule!) - let hasRegistered = await ParraGlobalState.shared.hasRegisteredModule(module: module) + let hasRegistered = await mockParra.parra.state.hasRegisteredModule( + module: fakeModule! + ) XCTAssertTrue(hasRegistered) - let modules = await ParraGlobalState.shared.getAllRegisteredModules() - XCTAssert(modules.keys.contains(FakeModule.name)) + let modules = await mockParra.parra.state.getAllRegisteredModules() + XCTAssert(modules.contains(where: { testModule in + return type(of: fakeModule!).name == type(of: testModule).name + })) XCTAssertEqual(modules.count, previous.count + 1) } - + func testLogout() async throws { - await Parra.logout() + await mockParra.parra.logout() - let currentCredential = await Parra.shared.dataManager.getCurrentCredential() + let currentCredential = await mockParra.dataManager.getCurrentCredential() XCTAssertNil(currentCredential) } } diff --git a/ParraTests/PersistentStorage/CredentialStorageTests.swift b/ParraTests/PersistentStorage/CredentialStorageTests.swift index 18c7c34fe..c5c03c980 100644 --- a/ParraTests/PersistentStorage/CredentialStorageTests.swift +++ b/ParraTests/PersistentStorage/CredentialStorageTests.swift @@ -14,7 +14,9 @@ class CredentialStorageTests: XCTestCase { override func setUpWithError() throws { storageModule = ParraStorageModule( - dataStorageMedium: .memory + dataStorageMedium: .memory, + jsonEncoder: .parraEncoder, + jsonDecoder: .parraDecoder ) credentialStorage = CredentialStorage( diff --git a/ParraTests/PersistentStorage/ParraStorageModuleTests.swift b/ParraTests/PersistentStorage/ParraStorageModuleTests.swift index 2d96dd10f..f57957c46 100644 --- a/ParraTests/PersistentStorage/ParraStorageModuleTests.swift +++ b/ParraTests/PersistentStorage/ParraStorageModuleTests.swift @@ -8,27 +8,47 @@ import XCTest @testable import Parra -typealias TestDataType = [String: String] +fileprivate typealias TestDataType = [String : String] -class ParraStorageModuleTests: XCTestCase { +class ParraStorageModuleTests: ParraBaseMock { var storageModules = [ParraStorageModule]() - - override func setUpWithError() throws { - try deleteDirectoriesInApplicationSupport() + + override func setUp() async throws { + try await super.setUp() + clearParraUserDefaultsSuite() - + let folder = "storage_modules" let file = "storage_data" - + storageModules = [ - .init(dataStorageMedium: .memory), - .init(dataStorageMedium: .fileSystem(folder: folder, fileName: file, storeItemsSeparately: true)), - .init(dataStorageMedium: .userDefaults(key: file)), + .init( + dataStorageMedium: .memory, + jsonEncoder: .parraEncoder, + jsonDecoder: .parraDecoder + ), + .init( + dataStorageMedium: .fileSystem( + baseUrl: baseStorageDirectory, + folder: folder, + fileName: file, + storeItemsSeparately: true, + fileManager: .default + ), + jsonEncoder: .parraEncoder, + jsonDecoder: .parraDecoder + ), + .init( + dataStorageMedium: .userDefaults(key: file), + jsonEncoder: .parraEncoder, + jsonDecoder: .parraDecoder + ), ] } - - override func tearDownWithError() throws { - try deleteDirectoriesInApplicationSupport() + + override func tearDown() async throws { + try await super.tearDown() + clearParraUserDefaultsSuite() } diff --git a/ParraTests/PersistentStorage/PersistentStorageMedium/FileSystemStorageTests.swift b/ParraTests/PersistentStorage/PersistentStorageMedium/FileSystemStorageTests.swift index a45540fc7..097dbcc40 100644 --- a/ParraTests/PersistentStorage/PersistentStorageMedium/FileSystemStorageTests.swift +++ b/ParraTests/PersistentStorage/PersistentStorageMedium/FileSystemStorageTests.swift @@ -8,28 +8,24 @@ import XCTest @testable import Parra -class FileSystemStorageTests: XCTestCase { +class FileSystemStorageTests: ParraBaseMock { private let fileManager = FileManager.default private var fileSystemStorage: FileSystemStorage! - private let baseUrl = ParraDataManager.Base.applicationSupportDirectory.safeAppendDirectory("files") - override func setUpWithError() throws { - try deleteDirectoriesInApplicationSupport() + override func setUp() async throws { + try await super.setUp() fileSystemStorage = FileSystemStorage( - baseUrl: baseUrl, + baseUrl: baseStorageDirectory, jsonEncoder: JSONEncoder(), - jsonDecoder: JSONDecoder() + jsonDecoder: JSONDecoder(), + fileManager: fileManager ) } - override func tearDownWithError() throws { - try deleteDirectoriesInApplicationSupport() - } - func testReadDoesNotExist() async throws { - let file: [String: String]? = try await fileSystemStorage.read( + let file: [String : String]? = try await fileSystemStorage.read( name: "file.txt" ) @@ -38,9 +34,9 @@ class FileSystemStorageTests: XCTestCase { func testReadFileDoesExist() async throws { let fileName = "file.txt" - let filePath = baseUrl.appendingPathComponent(fileName, isDirectory: false) - - let data: [String: String] = [ + let filePath = baseStorageDirectory.appendingPathComponent(fileName, isDirectory: false) + + let data: [String : String] = [ "aKey": "aValue" ] @@ -49,7 +45,7 @@ class FileSystemStorageTests: XCTestCase { contents: try JSONEncoder().encode(data) ) - let file: [String: String]? = try await fileSystemStorage.read( + let file: [String : String]? = try await fileSystemStorage.read( name: fileName ) @@ -59,8 +55,8 @@ class FileSystemStorageTests: XCTestCase { func testWriteFileExists() async throws { let name = "file2.dat" - let filePath = baseUrl.appendingPathComponent(name, isDirectory: false) - let data: [String: String] = [ + let filePath = baseStorageDirectory.appendingPathComponent(name, isDirectory: false) + let data: [String : String] = [ "key": "val" ] @@ -70,15 +66,15 @@ class FileSystemStorageTests: XCTestCase { ) let fileData = try Data(contentsOf: filePath) - let file = try JSONDecoder().decode([String: String].self, from: fileData) + let file = try JSONDecoder().decode([String : String].self, from: fileData) XCTAssertEqual(file, data) } func testDeleteFileDoesNotExist() async throws { let name = "file3.dat" - let filePath = baseUrl.appendingPathComponent(name, isDirectory: false) - let data: [String: String] = [ + let filePath = baseStorageDirectory.appendingPathComponent(name, isDirectory: false) + let data: [String : String] = [ "key": "val" ] diff --git a/ParraTests/PersistentStorage/PersistentStorageMedium/UserDefaultsStorageTests.swift b/ParraTests/PersistentStorage/PersistentStorageMedium/UserDefaultsStorageTests.swift index d7844c44d..1b541e422 100644 --- a/ParraTests/PersistentStorage/PersistentStorageMedium/UserDefaultsStorageTests.swift +++ b/ParraTests/PersistentStorage/PersistentStorageMedium/UserDefaultsStorageTests.swift @@ -22,7 +22,7 @@ class UserDefaultsStorageTests: XCTestCase { } func testReadDoesNotExist() async throws { - let file: [String: String]? = try await userDefaultsStorage.read( + let file: [String : String]? = try await userDefaultsStorage.read( name: "key1" ) @@ -31,13 +31,13 @@ class UserDefaultsStorageTests: XCTestCase { func testReadFileDoesExist() async throws { let key = "key2" - let data: [String: String] = [ + let data: [String : String] = [ "aKey": "aValue" ] userDefaults.set(try JSONEncoder().encode(data), forKey: key) - let readData: [String: String]? = try await userDefaultsStorage.read( + let readData: [String : String]? = try await userDefaultsStorage.read( name: key ) @@ -47,7 +47,7 @@ class UserDefaultsStorageTests: XCTestCase { func testWriteFileExists() async throws { let key = "key3" - let data: [String: String] = [ + let data: [String : String] = [ "key": "val" ] @@ -58,12 +58,12 @@ class UserDefaultsStorageTests: XCTestCase { let readData = userDefaults.value(forKey: key) as? Data XCTAssertNotNil(readData) - XCTAssertEqual(data, try JSONDecoder().decode([String: String].self, from: readData!)) + XCTAssertEqual(data, try JSONDecoder().decode([String : String].self, from: readData!)) } func testDeleteFileDoesNotExist() async throws { let key = "key4" - let data: [String: String] = [ + let data: [String : String] = [ "key": "val" ] diff --git a/ParraTests/SignpostTestObserver.swift b/ParraTests/SignpostTestObserver.swift new file mode 100644 index 000000000..a526e4491 --- /dev/null +++ b/ParraTests/SignpostTestObserver.swift @@ -0,0 +1,37 @@ +// +// SignpostTestObserver.swift +// ParraTests +// +// Created by Mick MacCallum on 10/8/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import Foundation +import XCTest +import OSLog + +/// https://www.iosdev.recipes/os-signpost/ +class SignpostTestObserver: NSObject, XCTestObservation { + + // Create a custom log subsystem and relevant category + let log = OSLog(subsystem: "com.parra.test-profiling", category: "signposts") + + override init() { + super.init() + + // XCTestObservation keeps a strong reference to observers + XCTestObservationCenter.shared.addTestObserver(self) + } + + // MARK: - Test Bundle + + func testBundleWillStart(_ testBundle: Bundle) { + let id = OSSignpostID(log: log, object: testBundle) + os_signpost(.begin, log: log, name: "test bundle", signpostID: id, "%@", testBundle.description) + } + + func testBundleDidFinish(_ testBundle: Bundle) { + let id = OSSignpostID(log: log, object: testBundle) + os_signpost(.end, log: log, name: "test bundle", signpostID: id, "%@", testBundle.description) + } +} diff --git a/ParraTests/TestHelpers/NetworkTestHelpers.swift b/ParraTests/TestHelpers/NetworkTestHelpers.swift deleted file mode 100644 index 3b876050e..000000000 --- a/ParraTests/TestHelpers/NetworkTestHelpers.swift +++ /dev/null @@ -1,106 +0,0 @@ -// -// NetworkTestHelpers.swift -// ParraTests -// -// Created by Mick MacCallum on 4/3/22. -// - -import Foundation -import XCTest -@testable import Parra - -@MainActor -extension XCTestCase { - @discardableResult - func configureWithRequestResolver( - resolver: @escaping (_ request: URLRequest) -> (Data?, HTTPURLResponse?, Error?) - ) async -> (ParraSessionManager, ParraSyncManager) { - let notificationCenter = ParraNotificationCenter.default - let dataManager = ParraDataManager() - let networkManager = ParraNetworkManager( - dataManager: dataManager, - urlSession: MockURLSession(dataTaskResolver: resolver) - ) - - let (tenantId, applicationId, authProvider) = Parra.withAuthenticationMiddleware( - for: .default(tenantId: "tenant", applicationId: "myapp", authProvider: { - return UUID().uuidString - }) - ) { _ in } - - var newConfig = ParraConfiguration.default - newConfig.setTenantId(tenantId) - newConfig.setApplicationId(applicationId) - - await ParraConfigState.shared.updateState(newConfig) - - await networkManager.updateAuthenticationProvider(authProvider) - - let sessionManager = ParraSessionManager( - dataManager: dataManager, - networkManager: networkManager - ) - - let syncManager = ParraSyncManager( - networkManager: networkManager, - sessionManager: sessionManager, - notificationCenter: notificationCenter - ) - - Parra.shared = Parra( - dataManager: dataManager, - syncManager: syncManager, - sessionManager: sessionManager, - networkManager: networkManager, - notificationCenter: notificationCenter - ) - - return (sessionManager, syncManager) - } - - @MainActor - func configureWithRequestResolverOnly( - resolver: @escaping (_ request: URLRequest) -> (Data?, HTTPURLResponse?, Error?) - ) async { - let notificationCenter = ParraNotificationCenter.default - let dataManager = ParraDataManager() - let networkManager = ParraNetworkManager( - dataManager: dataManager, - urlSession: MockURLSession(dataTaskResolver: resolver) - ) - - let sessionManager = ParraSessionManager( - dataManager: dataManager, - networkManager: networkManager - ) - - let syncManager = ParraSyncManager( - networkManager: networkManager, - sessionManager: sessionManager, - notificationCenter: notificationCenter - ) - - Parra.shared = Parra( - dataManager: dataManager, - syncManager: syncManager, - sessionManager: sessionManager, - networkManager: networkManager, - notificationCenter: notificationCenter - ) - } - -} - -func createTestResponse(route: String, - statusCode: Int = 200, - additionalHeaders: [String: String] = [:]) -> HTTPURLResponse { - - let url = Parra.InternalConstants.parraApiRoot.appendingPathComponent(route) - - return HTTPURLResponse( - url: url, - statusCode: statusCode, - httpVersion: "HTTP/1.1", - headerFields: [:].merging(additionalHeaders) { (_, new) in new } - )! -} diff --git a/ParraTests/TestHelpers/PersistentStorageTestHelpers.swift b/ParraTests/TestHelpers/PersistentStorageTestHelpers.swift index 0939a31c8..e57ce64ae 100644 --- a/ParraTests/TestHelpers/PersistentStorageTestHelpers.swift +++ b/ParraTests/TestHelpers/PersistentStorageTestHelpers.swift @@ -8,18 +8,6 @@ import Foundation @testable import Parra -func deleteDirectoriesInApplicationSupport() throws { - let directoryPaths = try FileManager.default.contentsOfDirectory( - at: ParraDataManager.Base.applicationSupportDirectory, - includingPropertiesForKeys: [.isDirectoryKey], - options: .skipsHiddenFiles - ) - - for directoryPath in directoryPaths { - try FileManager.default.removeItem(at: directoryPath) - } -} - func clearParraUserDefaultsSuite() { if let bundleIdentifier = Parra.bundle().bundleIdentifier { UserDefaults.standard.removePersistentDomain( diff --git a/ParraTests/Types/GeneratedTypes+Swift.swift b/ParraTests/Types/GeneratedTypes+Swift.swift index 237f4722d..feb2dd55b 100644 --- a/ParraTests/Types/GeneratedTypes+Swift.swift +++ b/ParraTests/Types/GeneratedTypes+Swift.swift @@ -11,357 +11,72 @@ import XCTest class GeneratedTypesTests: XCTestCase { func testCodingChoiceCard() throws { - try assertReencodedWithoutChange(object: sampleChoiceCard) + try assertReencodedWithoutChange( + object: TestData.Cards.choiceCard + ) } func testCodingCheckboxCard() throws { - try assertReencodedWithoutChange(object: sampleCheckboxCard) + try assertReencodedWithoutChange( + object: TestData.Cards.checkboxCard + ) } func testCodingBoolCard() throws { - try assertReencodedWithoutChange(object: sampleBoolCard) + try assertReencodedWithoutChange( + object: TestData.Cards.boolCard + ) } func testCodingStarCard() throws { - try assertReencodedWithoutChange(object: sampleStarCard) + try assertReencodedWithoutChange( + object: TestData.Cards.starCard + ) } func testCodingImageCard() throws { - try assertReencodedWithoutChange(object: sampleImageCard) + try assertReencodedWithoutChange( + object: TestData.Cards.imageCard + ) } func testCodingRatingCard() throws { - try assertReencodedWithoutChange(object: sampleRatingCard) + try assertReencodedWithoutChange( + object: TestData.Cards.ratingCard + ) } func testCodingShortTextCard() throws { - try assertReencodedWithoutChange(object: sampleShortTextCard) + try assertReencodedWithoutChange( + object: TestData.Cards.shortTextCard + ) } func testCodingLongTextCard() throws { - try assertReencodedWithoutChange(object: sampleLongTextCard) + try assertReencodedWithoutChange( + object: TestData.Cards.longTextCard + ) } func testCodingFullCardResponse() throws { - try assertReencodedWithoutChange(object: sampleCardsResponse) + try assertReencodedWithoutChange( + object: TestData.Cards.cardsResponse + ) } - private func assertReencodedWithoutChange(object: T) throws { + private func assertReencodedWithoutChange( + object: T + ) throws { let reencoded = try reencode(object: object) + XCTAssertEqual(object, reencoded) } - private func reencode(object: T) throws -> T { + private func reencode( + object: T + ) throws -> T { let encoded = try JSONEncoder().encode(object) + return try JSONDecoder().decode(T.self, from: encoded) } } - -let sampleChoiceCard = ParraCardItem( - id: "id", - campaignId: "", - campaignActionId: "", - questionId: "qid", - type: .question, - displayType: .drawer, - version: "1", - data: .question( - Question( - id: "1", - createdAt: Date().ISO8601Format(), - updatedAt: Date().ISO8601Format(), - deletedAt: nil, - tenantId: "24234234", - title: "Sample question 1", - subtitle: nil, - kind: .radio, - data: .choiceQuestionBody( - ChoiceQuestionBody( - options: [ - ChoiceQuestionOption( - title: "option 1", - value: "", - isOther: nil, - id: "op1" - ), - ChoiceQuestionOption( - title: "option 2", - value: "", - isOther: nil, - id: "op2" - ) - ] - ) - ), - active: true, - expiresAt: Date().addingTimeInterval(100000).ISO8601Format(), - answerQuota: nil, - answer: nil - ) - ) -) - -let sampleCheckboxCard = ParraCardItem( - id: "id", - campaignId: "", - campaignActionId: "", - questionId: "qid", - type: .question, - displayType: .inline, - version: "1", - data: .question( - Question( - id: "2", - createdAt: Date().ISO8601Format(), - updatedAt: Date().ISO8601Format(), - deletedAt: nil, - tenantId: "24234234", - title: "Sample question 2", - subtitle: "this one has a subtitle", - kind: .checkbox, - data: .checkboxQuestionBody( - CheckboxQuestionBody( - options: [ - CheckboxQuestionOption( - title: "option 1", - value: "", - isOther: nil, - id: "op1" - ), - CheckboxQuestionOption( - title: "option 2", - value: "", - isOther: nil, - id: "op2" - ) - ] - ) - ), - active: false, - expiresAt: nil, - answerQuota: nil, - answer: nil - ) - ) -) - -let sampleBoolCard = ParraCardItem( - id: "id", - campaignId: "", - campaignActionId: "", - questionId: "qid", - type: .question, - displayType: .popup, - version: "1", - data: .question( - Question( - id: "3", - createdAt: Date().ISO8601Format(), - updatedAt: Date().ISO8601Format(), - deletedAt: nil, - tenantId: "24234234", - title: "Sample question 2", - subtitle: "this one has a subtitle", - kind: .boolean, - data: .booleanQuestionBody( - BooleanQuestionBody(options: [ - BooleanQuestionOption(title: "title", value: "value", id: "id"), - BooleanQuestionOption(title: "title", value: "value", id: "id"), - ]) - ), - active: false, - expiresAt: nil, - answerQuota: nil, - answer: nil - ) - ) -) - -let sampleStarCard = ParraCardItem( - id: "id", - campaignId: "", - campaignActionId: "", - questionId: "qid", - type: .question, - displayType: .inline, - version: "1", - data: .question( - Question( - id: "4", - createdAt: Date().ISO8601Format(), - updatedAt: Date().ISO8601Format(), - deletedAt: nil, - tenantId: "24234234", - title: "Sample question 2", - subtitle: "this one has a subtitle", - kind: .star, - data: .starQuestionBody( - StarQuestionBody( - starCount: 5, - leadingLabel: "leading", - centerLabel: "center", - trailingLabel: "trailing" - ) - ), - active: false, - expiresAt: nil, - answerQuota: nil, - answer: nil - ) - ) -) - -let sampleImageCard = ParraCardItem( - id: "id", - campaignId: "", - campaignActionId: "", - questionId: "qid", - type: .question, - displayType: .drawer, - version: "1", - data: .question( - Question( - id: "5", - createdAt: Date().ISO8601Format(), - updatedAt: Date().ISO8601Format(), - deletedAt: nil, - tenantId: "24234234", - title: "Sample question 2", - subtitle: "this one has a subtitle", - kind: .image, - data: .imageQuestionBody( - ImageQuestionBody( - options: [ - ImageQuestionOption( - title: "title", - value: "val", - id: "id", - asset: Asset(id: "id", url: URL(string: "parra.io/image.png")!) - ), - ImageQuestionOption( - title: "title2", - value: "val2", - id: "id2", - asset: Asset(id: "id2222", url: URL(string: "parra.io/image2.png")!) - ) - ] - ) - ), - active: false, - expiresAt: nil, - answerQuota: nil, - answer: nil - ) - ) -) - -let sampleRatingCard = ParraCardItem( - id: "id", - campaignId: "", - campaignActionId: "", - questionId: "qid", - type: .question, - displayType: .popup, - version: "1", - data: .question( - Question( - id: "6", - createdAt: Date().ISO8601Format(), - updatedAt: Date().ISO8601Format(), - deletedAt: nil, - tenantId: "24234234", - title: "Sample question 2", - subtitle: "this one has a subtitle", - kind: .rating, - data: .ratingQuestionBody( - RatingQuestionBody( - options: [ - RatingQuestionOption(title: "title1", value: 1, id: "1"), - RatingQuestionOption(title: "title2", value: 2, id: "2"), - RatingQuestionOption(title: "title3", value: 3, id: "3"), - RatingQuestionOption(title: "title4", value: 4, id: "4"), - RatingQuestionOption(title: "title5", value: 5, id: "5"), - ], - leadingLabel: "leading", - centerLabel: "center", - trailingLabel: "trailing" - ) - ), - active: false, - expiresAt: nil, - answerQuota: nil, - answer: nil - ) - ) -) - -let sampleShortTextCard = ParraCardItem( - id: "id", - campaignId: "", - campaignActionId: "", - questionId: "qid", - type: .question, - displayType: .inline, - version: "1", - data: .question( - Question( - id: "7", - createdAt: Date().ISO8601Format(), - updatedAt: Date().ISO8601Format(), - deletedAt: nil, - tenantId: "24234234", - title: "Sample question 2", - subtitle: "this one has a subtitle", - kind: .textShort, - data: .shortTextQuestionBody( - ShortTextQuestionBody(placeholder: "placeholder", minLength: 50) - ), - active: false, - expiresAt: nil, - answerQuota: nil, - answer: nil - ) - ) -) - -let sampleLongTextCard = ParraCardItem( - id: "id", - campaignId: "", - campaignActionId: "", - questionId: "qid", - type: .question, - displayType: .popup, - version: "1", - data: .question( - Question( - id: "7", - createdAt: Date().ISO8601Format(), - updatedAt: Date().ISO8601Format(), - deletedAt: nil, - tenantId: "24234234", - title: "Sample question 2", - subtitle: "this one has a subtitle", - kind: .textLong, - data: .longTextQuestionBody( - LongTextQuestionBody(placeholder: "placeholder", minLength: 1, maxLength: 1000) - ), - active: false, - expiresAt: nil, - answerQuota: nil, - answer: nil - ) - ) -) - -let sampleCardsResponse = CardsResponse( - items: [ - sampleChoiceCard, - sampleCheckboxCard, - sampleBoolCard, - sampleStarCard, - sampleImageCard, - sampleRatingCard, - sampleShortTextCard, - sampleLongTextCard - ] -) diff --git a/ParraTests/Types/ParraCredentialTests.swift b/ParraTests/Types/ParraCredentialTests.swift index e22604ba8..f452cbab2 100644 --- a/ParraTests/Types/ParraCredentialTests.swift +++ b/ParraTests/Types/ParraCredentialTests.swift @@ -20,7 +20,7 @@ final class ParraCredentialTests: XCTestCase { func testEncodesToToken() throws { let data = try JSONEncoder().encode(ParraCredential(token: "something")) - let decoded = try JSONDecoder().decode([String: String].self, from: data) + let decoded = try JSONDecoder().decode([String : String].self, from: data) XCTAssert(decoded["token"] != nil) } diff --git a/ParraTests/Util/LoggerHelpersTests.swift b/ParraTests/Util/LoggerHelpersTests.swift new file mode 100644 index 000000000..a0d30cd54 --- /dev/null +++ b/ParraTests/Util/LoggerHelpersTests.swift @@ -0,0 +1,158 @@ +// +// LoggerHelpersTests.swift +// ParraTests +// +// Created by Mick MacCallum on 6/25/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import XCTest +@testable import Parra + +fileprivate enum LoggerTestError: Error { + case exception + case uniquelyNamedErrorCase +} + +final class LoggerHelpersTests: XCTestCase { + // MARK: - extractMessage + + func testExtractErrorMessageReturnsCustomParraErrors() { + let extraMessage = "something" + let error = ParraError.authenticationFailed(extraMessage) + + let result = LoggerHelpers.extractMessageAndExtra(from: error) + + XCTAssertEqual(result.message, error.errorDescription) + } + + func testExtractErrorMessageFromNsErrors() { + let domain = "testDomain" + let localizedDescription = "error description wooo" + let code = 420 + + let error = NSError( + domain: domain, + code: code, + userInfo: [ + NSLocalizedDescriptionKey: localizedDescription + ] + ) + + let result = LoggerHelpers.extractMessageAndExtra(from: error) + + XCTAssertTrue(result.message.contains(localizedDescription)) + XCTAssertEqual(result.extra?["domain"] as? String, domain) + XCTAssertEqual(result.extra?["code"] as? Int, code) + } + + func testExtractErrorMessageFromErrorProtocolTypes() { + let error = LoggerTestError.uniquelyNamedErrorCase + let result = LoggerHelpers.extractMessageAndExtra(from: error) + + XCTAssertTrue(result.message.contains("LoggerTestError.uniquelyNamedErrorCase")) + } + + // MARK: splitFileId + + func testSplitExpectedFileId() { + splitAndAssertEqual( + fileId: "Parra/LoggerHelpers.swift", + expectedModule: "Parra", + expectedFileName: "LoggerHelpers", + expectedExtension: "swift" + ) + } + + func testSplitEmptyFileId() { + splitAndAssertEqual( + fileId: "", + expectedModule: "Unknown", + expectedFileName: "Unknown", + expectedExtension: nil + ) + } + + func testSplitMissingModuleFileId() { + splitAndAssertEqual( + fileId: "LoggerHelpers.swift", + expectedModule: "Unknown", + expectedFileName: "LoggerHelpers", + expectedExtension: "swift" + ) + } + + func testSplitExtraFilePathComponentsFileId() { + splitAndAssertEqual( + fileId: "Parra/Intermediate/Folders/LoggerHelpers.swift", + expectedModule: "Parra", + expectedFileName: "Intermediate/Folders/LoggerHelpers", + expectedExtension: "swift" + ) + } + + func testSplitMissingExtensionFileId() { + splitAndAssertEqual( + fileId: "Parra/LoggerHelpers", + expectedModule: "Parra", + expectedFileName: "LoggerHelpers", + expectedExtension: nil + ) + } + + func testSplitMultipleFileExtensionsFileId() { + splitAndAssertEqual( + fileId: "Parra/LoggerHelpers.swift.gz", + expectedModule: "Parra", + expectedFileName: "LoggerHelpers", + expectedExtension: "swift.gz" + ) + } + + // MARK: - createFormattedLocation + + func testFormatsLogLocation() { + let slug = LoggerHelpers.createFormattedLocation( + fileId: "Parra/LoggerHelpers.swift", + function: "createFormattedLocation(fileID:function:line:)", + line: 69 + ) + + XCTAssertEqual(slug, "Parra/LoggerHelpers.createFormattedLocation#69") + } + + func testFormatsLogLocationMissingFileExtension() { + let slug = LoggerHelpers.createFormattedLocation( + fileId: "Parra/LoggerHelpers", + function: "createFormattedLocation(fileID:function:line:)", + line: 69 + ) + + XCTAssertEqual(slug, "Parra/LoggerHelpers.createFormattedLocation#69") + } + + func testFormatsLogLocationForFunctionsWithoutParams() { + let slug = LoggerHelpers.createFormattedLocation( + fileId: "Parra/LoggerHelpers.swift", + function: "createFormattedLocation", + line: 69 + ) + + XCTAssertEqual(slug, "Parra/LoggerHelpers.createFormattedLocation#69") + } + + private func splitAndAssertEqual( + fileId: String, + expectedModule: String, + expectedFileName: String, + expectedExtension: String? + ) { + let (module, fileName, ext) = LoggerHelpers.splitFileId( + fileId: fileId + ) + + XCTAssertTrue(module == expectedModule, "Expected module from: \(fileId) to be \(expectedModule)") + XCTAssertTrue(fileName == expectedFileName, "Expected fileName from: \(fileId) to be \(expectedFileName)") + XCTAssertTrue(ext == expectedExtension, "Expected extension from: \(fileId) to be \(String(describing: expectedExtension))") + } +} diff --git a/README.md b/README.md index 67046e3de..a63c3cfa5 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,24 @@ The Parra iOS SDK allows you to quickly and easily collect valuable product feed ## Requirements -The Parra iOS SDK can be built using Xcode 14.3.1 or later and can be installed in apps targetting iOS 15 or greater. +The Parra iOS SDK can be built using Xcode 15.1 or later and can be installed in apps targetting iOS 17.0 or greater. ## Getting Started If you're ready to install the Parra iOS SDK in your own project, check out the [iOS SDK integration guide](https://docs.parra.io/guides/ios). If you'd like to see Parra in action, clone this repo and run the [Demo target](https://github.com/Parra-Inc/parra-ios-sdk/tree/main/Demo) in the included Xcode project. There you can find examples of how to customize and add Parra Feedbacks in your app. +### Project Setup + + + +#### CLI + +Instructions for setting up and running scripts can be found [here](cli/README.md). + + + ## License - [Parra iOS SDK License](https://github.com/Parra-Inc/parra-ios-sdk/blob/main/LICENSE.md) + + diff --git a/TestRunner/AppDelegate.swift b/TestRunner/AppDelegate.swift new file mode 100644 index 000000000..d985e3da5 --- /dev/null +++ b/TestRunner/AppDelegate.swift @@ -0,0 +1,31 @@ +// +// AppDelegate.swift +// TestRunner +// +// Created by Mick MacCallum on 10/16/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return true + } + + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + return UISceneConfiguration( + name: "Default Configuration", + sessionRole: connectingSceneSession.role + ) + } +} + diff --git a/TestRunner/Assets.xcassets/AccentColor.colorset/Contents.json b/TestRunner/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/TestRunner/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TestRunner/Assets.xcassets/AppIcon.appiconset/Contents.json b/TestRunner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..13613e3ee --- /dev/null +++ b/TestRunner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TestRunner/Assets.xcassets/Contents.json b/TestRunner/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/TestRunner/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TestRunner/Base.lproj/LaunchScreen.storyboard b/TestRunner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 000000000..865e9329f --- /dev/null +++ b/TestRunner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TestRunner/Base.lproj/Main.storyboard b/TestRunner/Base.lproj/Main.storyboard new file mode 100644 index 000000000..81eccf4fc --- /dev/null +++ b/TestRunner/Base.lproj/Main.storyboard @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TestRunner/Info.plist b/TestRunner/Info.plist new file mode 100644 index 000000000..dd3c9afda --- /dev/null +++ b/TestRunner/Info.plist @@ -0,0 +1,25 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + + diff --git a/TestRunner/SceneDelegate.swift b/TestRunner/SceneDelegate.swift new file mode 100644 index 000000000..98cf5323f --- /dev/null +++ b/TestRunner/SceneDelegate.swift @@ -0,0 +1,11 @@ +// +// SceneDelegate.swift +// TestRunner +// +// Created by Mick MacCallum on 10/16/23. +// Copyright © 2023 Parra, Inc. All rights reserved. +// + +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate {} diff --git a/TestRunner/TestRunner.entitlements b/TestRunner/TestRunner.entitlements new file mode 100644 index 000000000..903def2af --- /dev/null +++ b/TestRunner/TestRunner.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 000000000..eb93e6964 Binary files /dev/null and b/bun.lockb differ diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 000000000..f6f112f75 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,3 @@ + +[install] +exact = true diff --git a/cli.sh b/cli.sh new file mode 100755 index 000000000..9ef82eabf --- /dev/null +++ b/cli.sh @@ -0,0 +1,5 @@ +#! /bin/bash + +### Run `./cli.sh --help` for more information + +npx tsx cli/index.ts $@ diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 000000000..3c62a27d2 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,23 @@ +# Parra iOS SDK CLI + +This CLI contains tools that are useful for the development and deployment of the Parra iOS SDK. If you're looking to integrate Parra into your iOS project, the contents of this directory can be ignored. The Parra CLI is designed to be flexible enough to be invoked locally during development, as well as during CI. The configuration for the latter can be found in [.circle/config.yml](https://github.com/Parra-Inc/parra-ios-sdk/blob/main/.circleci/config.yml). + +## Philosophy + +Scripts designed to assist with the testing and deployment of an SDK, such as Parra, should make best efforts to always prioritize reliability, speed, and clarity. In that order. We would rather incur a more complicated initial setup cost than suffer maintenance of flaky or slow jobs and tests (particularly when they are run in a CI environment). To this end, you will find that most scripts interact with build tools directly and avoid using tools like Fastlane when possible. For some of the rationale on why, see [Life in the slow lane](https://silverhammermba.github.io/blog/2019/03/12/slowlane). + +## Getting Started + +1. Clone the repo +2. Install the Xcode version specified in the main [README](../README.md) file. +3. Install [Bun](https://bun.sh/). +4. Run `bun install` from the root of the repository. +5. Invoke the Parra CLI by running `./cli.sh --help` to learn more about the available commands. + +## Common Use Cases + +#### Building to Run Unit Tests + +```{sh} +./cli.sh tests --build --run --log-level debug +``` diff --git a/cli/bin/obtain-simulator-device-id.sh b/cli/bin/obtain-simulator-device-id.sh new file mode 100755 index 000000000..c3996cc12 --- /dev/null +++ b/cli/bin/obtain-simulator-device-id.sh @@ -0,0 +1,81 @@ +#! /bin/bash + +# Obtains the UDID of the first available simulator device matching the input args. +# Args should be: + +log() { + echo "$1" >&2 +} + + +if [ -z "$1" ]; then + log "Device name not specified." + exit 1 +fi + +if [ -z "$2" ]; then + log "Device OS version not specified." + exit 1 +fi + +deviceName="$1" +deviceOsVersion="$2" + + +log "Searching for iOS simulator matching $deviceName ($deviceOsVersion)" + +found=$(applesimutils --byName "$deviceName" --byOS "$deviceOsVersion" --list --maxResults 1 | jq '.[0].udid' -r) + +# Sample response JSON from applesimutils: +# { +# "os": { +# "bundlePath": "/Library/Developer/CoreSimulator/Volumes/iOS_21C62/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 17.2.simruntime", +# "buildversion": "21C62", +# "platform": "iOS", +# "runtimeRoot": "/Library/Developer/CoreSimulator/Volumes/iOS_21C62/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 17.2.simruntime/Contents/Resources/RuntimeRoot", +# "identifier": "com.apple.CoreSimulator.SimRuntime.iOS-17-2", +# "version": "17.2", +# "isInternal": false, +# "isAvailable": true, +# "name": "iOS 17.2", +# "supportedDeviceTypes": [ +# { +# "bundlePath": "/Applications/Xcode-15.1.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/DeviceTypes/iPhone Xs.simdevicetype", +# "name": "iPhone Xs", +# "identifier": "com.apple.CoreSimulator.SimDeviceType.iPhone-XS", +# "productFamily": "iPhone" +# }, +# // .... many more devices +# ] +# }, +# "dataPath": "/Users/mick/Library/Developer/CoreSimulator/Devices/CEA23780-96E6-44F5-8ED9-0D6EDE193CDF/data", +# "dataPathSize": 18337792, +# "logPath": "/Users/mick/Library/Logs/CoreSimulator/CEA23780-96E6-44F5-8ED9-0D6EDE193CDF", +# "udid": "CEA23780-96E6-44F5-8ED9-0D6EDE193CDF", +# "isAvailable": true, +# "deviceType": { +# "maxRuntimeVersion": 4294967295, +# "bundlePath": "/Applications/Xcode-15.1.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/DeviceTypes/iPhone 15.simdevicetype", +# "maxRuntimeVersionString": "65535.255.255", +# "name": "iPhone 15", +# "identifier": "com.apple.CoreSimulator.SimDeviceType.iPhone-15", +# "productFamily": "iPhone", +# "modelIdentifier": "iPhone15,4", +# "minRuntimeVersionString": "17.0.0", +# "minRuntimeVersion": 1114112 +# }, +# "deviceTypeIdentifier": "com.apple.CoreSimulator.SimDeviceType.iPhone-15", +# "state": "Shutdown", +# "name": "iPhone 15" +# } + +if [ -z "$found" ]; then + >&2 echo "Unable to find matching device!" + exit 2 +fi + +log "Found matching device: $found" + +echo "$found" + +exit 0 \ No newline at end of file diff --git a/cli/bin/preboot-simulator.sh b/cli/bin/preboot-simulator.sh new file mode 100755 index 000000000..a049e1bb5 --- /dev/null +++ b/cli/bin/preboot-simulator.sh @@ -0,0 +1,14 @@ +#! /bin/bash + +# This is used instead of the macOS ORB's preboot-simulator step, since it takes too long to return. + +deviceId=`./cli/bin/obtain-simulator-device-id.sh "$PARRA_TEST_DEVICE_NAME" "$PARRA_TEST_DEVICE_OS_VERSION"` + +echo "Booting simulator with device ID: $deviceId" + +# Store the device ID in an environment variable so it can be used by other steps. +export PARRA_TEST_DEVICE_UDID="$deviceId" + +# Boot the device but don't wait for it to finish. We want to get through other preparations while it's booting +# and there will be another task later that waits for it to finish booting before starting tests. +nohup xcrun simctl boot $deviceId --arch=arm64 & diff --git a/cli/ci/README.md b/cli/ci/README.md new file mode 100644 index 000000000..ed44d3ae3 --- /dev/null +++ b/cli/ci/README.md @@ -0,0 +1,17 @@ +# CI Scripts + +This directory contains scripts that intended to be used from within a CI environment for things like running tests and building/shipping releases. Most of the scripts within this directory will be invoked automatically from [.circle/config.yml](https://github.com/Parra-Inc/parra-ios-sdk/blob/main/.circleci/config.yml). + +## Philosophy + +CI scripts should make best efforts to always prioritize reliability, speed, and clarity. In that order. We would rather incur a more complicated initial setup cost than suffer maintenance of flaky or slow jobs and tests. To this end, you will find that most scripts interact with build tools directly and avoid using tools like Fastlane when possible. For some of the rationale on why, see [Life in the slow lane](https://silverhammermba.github.io/blog/2019/03/12/slowlane). + +## Scripts + +#### Building to Run Unit Tests + +`./build-for-testing.sh` performs a clean build of all Parra targets needed to execute unit tests. It also prepares a blank test runner app target to avoid running tests on the demo app, which may interfer with the tests. + +#### Running Unit Tests + +`./run-unit-tests.sh` will attempt to run the tests without building. If unable to do so, an incremental build will be performed. If the tests fail after this, a clean rebuild will happen and the tests will be run again. diff --git a/cli/ci/disable-simulator-hardware-keyboard.ts b/cli/ci/disable-simulator-hardware-keyboard.ts new file mode 100644 index 000000000..bf89a66fd --- /dev/null +++ b/cli/ci/disable-simulator-hardware-keyboard.ts @@ -0,0 +1,15 @@ +import { runCommand } from '../utils/command.js'; + +export const disableSimulatorHardwareKeyboard = async () => { + await runCommand( + ` + echo "Killing simulator" + set +e + + killall Simulator + + echo "Disabling hardware keyboard" + defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false + ` + ); +}; diff --git a/cli/ci/index.ts b/cli/ci/index.ts new file mode 100644 index 000000000..491081d17 --- /dev/null +++ b/cli/ci/index.ts @@ -0,0 +1,2 @@ +export { disableSimulatorHardwareKeyboard } from './disable-simulator-hardware-keyboard.js'; +export { installBrewDependencies } from './install-brew-dependencies.js'; diff --git a/cli/ci/install-brew-dependencies.ts b/cli/ci/install-brew-dependencies.ts new file mode 100644 index 000000000..95bd1dec5 --- /dev/null +++ b/cli/ci/install-brew-dependencies.ts @@ -0,0 +1,30 @@ +import { runThrowingCommand } from '../utils/command.js'; + +export const installBrewDependencies = async () => { + await runThrowingCommand( + ` + echo "Installing Homebrew dependencies..." + + # Don't check for and install updates for taps on every brew command. + export HOMEBREW_NO_AUTO_UPDATE=1 + + # Speed up by skipping cleanup tasks after install. + export HOMEBREW_NO_INSTALL_CLEANUP=1 + + export HOMEBREW_NO_INSTALL_UPGRADE=1 + + export HOMEBREW_NO_ANALYTICS=1 + + + + echo "Installing applesimutils..." + # Used for scripts that query for specific simulators via simctl. + brew tap wix/brew --quiet && brew install applesimutils --quiet + + + echo "Installing xcbeautify..." + # Used for pretty printing xcodebuild output. + brew install xcbeautify --quiet + ` + ); +}; diff --git a/cli/commands/ci.ts b/cli/commands/ci.ts new file mode 100644 index 000000000..c549391e2 --- /dev/null +++ b/cli/commands/ci.ts @@ -0,0 +1,49 @@ +import { Command, Option } from 'commander'; +import Logger from '../utils/logger/logger.js'; +import { + disableSimulatorHardwareKeyboard, + installBrewDependencies, +} from '../ci/index.js'; + +export const command = (logger: Logger): Command => { + return new Command('ci') + .description( + 'Helper scripts specifically indended for use in CI environments.' + ) + .addOption( + new Option( + '-d, --disable-simulator-hardware-keyboard', + 'Disables the setting for the iOS Simulator to prevent automatically connecting to the hardware keyboard.' + ) + ) + .addOption( + new Option( + '-b, --install-brew-dependencies', + 'Installs dependencies using Homebrew.' + ) + ) + .action(async (options) => { + const { + disableSimulatorHardwareKeyboard: disableKeyboard, + installBrewDependencies: brewInstall, + } = options; + + if (brewInstall) { + await installBrewDependencies(); + } + + if (disableKeyboard) { + await disableSimulatorHardwareKeyboard(); + } + + Logger.setGlobalLogLevel(options.silent ? 'silent' : options.logLevel); + + try { + logger.success('Done!'); + } catch (error) { + console.error(error); + + process.exit(1); + } + }); +}; diff --git a/cli/commands/release.ts b/cli/commands/release.ts new file mode 100644 index 000000000..220b49ff5 --- /dev/null +++ b/cli/commands/release.ts @@ -0,0 +1,20 @@ +import { Command } from 'commander'; +import Logger from '../utils/logger/logger.js'; + +export const command = (logger: Logger): Command => { + return new Command('release') + .description( + 'Helper scripts for releasing and publishing new versions of the library.' + ) + .action(async (options) => { + Logger.setGlobalLogLevel(options.silent ? 'silent' : options.logLevel); + + try { + logger.success('Done!'); + } catch (error) { + console.error(error); + + process.exit(1); + } + }); +}; diff --git a/cli/commands/tests.ts b/cli/commands/tests.ts new file mode 100644 index 000000000..1227d46c5 --- /dev/null +++ b/cli/commands/tests.ts @@ -0,0 +1,55 @@ +import { Command, Option } from 'commander'; +import Logger from '../utils/logger/logger.js'; +import { buildTests, runTests } from '../tests/index.js'; + +export const command = (logger: Logger): Command => { + return new Command('tests') + .description('Build and run tests for the Parra iOS SDK.') + .addOption( + new Option( + '-b, --build', + 'Builds Parra in preparation for running tests without actually running them.' + ).env('PARRA_TEST_OPTION_BUILD') + ) + .addOption( + new Option('-r, --run', "Runs Parra's test suites").env( + 'PARRA_TEST_OPTION_RUN' + ) + ) + .addOption( + new Option('-c --coverage', 'Enable code coverage') + .env('PARRA_TEST_OPTION_COVERAGE') + .implies({ run: true }) + ) + .addOption( + new Option( + '-o --output-directory', + 'The location to store testing artifacts' + ) + .env('PARRA_TEST_OUTPUT_DIRECTORY') + .makeOptionMandatory(true) + .default('artifacts') + ) + .action(async (options) => { + const { build, run } = options; + + Logger.setGlobalLogLevel(options.silent ? 'silent' : options.logLevel); + + try { + if (build) { + await buildTests(); + } + + // Not else-if because we want to both build and run tests if both flags are provided. + if (run) { + await runTests({ enableCodeCoverage: true, resultBundlePath: '' }); + } + + logger.success('Done!'); + } catch (error) { + console.error(error); + + process.exit(1); + } + }); +}; diff --git a/cli/commands/utils.ts b/cli/commands/utils.ts new file mode 100644 index 000000000..9a439777f --- /dev/null +++ b/cli/commands/utils.ts @@ -0,0 +1,43 @@ +import { Command, Option } from 'commander'; +import Logger from '../utils/logger/logger.js'; +import { + openAppContainerForDemoApp, + openAppContainerForTestRunnerApp, +} from '../utils/openAppContainer.js'; + +export const command = (logger: Logger): Command => { + return new Command('utils') + .description('A way to invoke miscellaneous utilities and helper scripts.') + .addOption( + new Option( + '-a --open-app-data', + 'Opens a new Finder window to the app data directory for the Parra Demo app in the currently booted simulator.' + ) + ) + .addOption( + new Option( + '-t --open-test-data', + 'Opens a new Finder window to the app data directory for the Parra Test Runner app in the currently booted simulator.' + ) + ) + .action(async (options) => { + Logger.setGlobalLogLevel(options.silent ? 'silent' : options.logLevel); + const { openAppData, openTestData } = options; + + try { + if (openAppData) { + await openAppContainerForDemoApp(); + } + + if (openTestData) { + await openAppContainerForTestRunnerApp(); + } + + logger.success('Done!'); + } catch (error) { + console.error(error); + + process.exit(1); + } + }); +}; diff --git a/cli/index.ts b/cli/index.ts new file mode 100755 index 000000000..a3cb57743 --- /dev/null +++ b/cli/index.ts @@ -0,0 +1,87 @@ +import { readdirSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +import { program, Option, Command } from 'commander'; +import Logger from './utils/logger/logger.js'; +import { ParraCliCommandGenerator } from './types.js'; + +// Commands issued to the CLI should provide args in the following format: +// $ [options] +// For example, to build tests, the command would be: $ tests --build +// Individual commands should be implemented as their own files in the commands directory. +// Commands created this way will be automatically added to the CLI. + +const logger = new Logger('cli'); + +export const commonOptions = [ + new Option( + '-l, --log-level [string]', + 'The verbosity level to use for logger output.' + ) + .choices(['debug', 'info', 'warn', 'error', 'silent']) + .conflicts('silent') + .default('info') + .makeOptionMandatory(true), + new Option('--silent', 'Do not output any logs') + .implies({ + 'log-level': 'silent', + }) + .default(false) + .conflicts('log-level'), +]; + +const commandsDirName = 'commands'; +const __filename = fileURLToPath(import.meta.url); +const normalizedPath = join(dirname(__filename), commandsDirName); + +// Create a command for each file in the commands directory. +const commands = readdirSync(normalizedPath).reduce((acc, file) => { + if (!file.endsWith('.ts')) { + logger.warn( + `Skipping loading command at ${file} because it is not a TypeScript file.` + ); + + return acc; + } + + const module = require(`./${commandsDirName}/${file}`); + if (!module.command || typeof module.command !== 'function') { + logger.warn( + `Skipping loading command at ${file} because it does not export a command function.` + ); + + return acc; + } + + let commandWithDefaults = (module.command as ParraCliCommandGenerator)(logger) + .showHelpAfterError(true) + .addHelpCommand(true); + + for (const option of commonOptions) { + commandWithDefaults = commandWithDefaults.addOption(option); + } + + return [...acc, commandWithDefaults]; +}, [] as Command[]); + +let finalProgram = program + .name('🦜🦜🦜 Parra CLI 🦜🦜🦜') + .description( + ` + A set of tools for working with Parra. This includes running tests and creating releases. + + These scripts are intended to be run both locally and from within a CI environment, and should + always be run from the root of the Parra repository. + ` + ) + .version(process.env.npm_package_version, '-v, --version') + .addHelpCommand(true, 'Opens this help menu.'); + +for (const command of commands) { + finalProgram = finalProgram.addCommand(command); +} + +finalProgram.parse(); diff --git a/cli/release/pod-release.js b/cli/release/pod-release.js new file mode 100755 index 000000000..281fb5071 --- /dev/null +++ b/cli/release/pod-release.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node + +// const argv = yargs(hideBin(process.argv)) +// .version(false) +// .option('tag', { // can't use --version or -v since they interfer with npm's version arg. +// alias: 't', +// type: 'string', +// description: 'The tag/version that you want to release.', +// demandOption: true, +// }) +// .help() +// .argv + +// const { tag } = argv; +// const podSpec = `Parra.podspec`; +// const gitTag = `parra-${tag}`; + +// (async () => { +// try { +// console.log((await exec('bundle install')).stdout); + +// console.log((await exec(`ruby ./scripts/ci/set-framework-version.rb ${tag}`)).stdout); + +// console.log((await exec('pod repo update')).stdout); + +// console.log(`[PARRA CLI] Preparing to release version: ${tag} of ${podSpec}`); + +// const status = await (await exec('git status -s')).stdout; +// if (status.length > 0) { +// console.log((await exec(`git add -A && git commit -m "Release v${tag}"`)).stdout); +// } + +// console.log(await exec(`git tag "${gitTag}"`)); +// console.log((await exec('git push')).stdout); +// console.log((await exec('git push --tags')).stdout); + +// const { stdout, stderr } = await exec( +// `PARRA_TAG="${gitTag}" PARRA_VERSION="${tag}" pod trunk push ${podSpec} --allow-warnings` +// ); +// console.error(stderr); +// console.log(stdout); + +// console.log(`[PARRA CLI] successfully published release version: ${tag} of ${podSpec}`); +// } catch (error) { +// console.error(error); +// throw error; +// } +// })(); diff --git a/scripts/set-framework-version.rb b/cli/release/set-framework-version.rb similarity index 100% rename from scripts/set-framework-version.rb rename to cli/release/set-framework-version.rb diff --git a/cli/tests/build-tests.ts b/cli/tests/build-tests.ts new file mode 100644 index 000000000..8f44f5805 --- /dev/null +++ b/cli/tests/build-tests.ts @@ -0,0 +1,23 @@ +import Logger from '../utils/logger/logger.js'; +import { BuildForTestingOptions } from './types.js'; +import { loadBuildForTestingOptionsFromEnvironment } from './utils.js'; +import { buildForTesting } from './xcode-build.js'; + +const logger = new Logger('build-tests'); + +export const buildTests = async () => { + logger.info('Starting build for testing...'); + + const options = await loadBuildForTestingOptionsFromEnvironment(); + logger.debug('Finished loading options from environment'); + + const allowProvisioning = !!options.authenticationKeyPath; + + const fullOptions: BuildForTestingOptions = { + ...options, + allowProvisioningUpdates: allowProvisioning, + allowProvisioningDeviceRegistration: allowProvisioning, + }; + + await buildForTesting(fullOptions); +}; diff --git a/cli/tests/index.ts b/cli/tests/index.ts new file mode 100644 index 000000000..0eea53097 --- /dev/null +++ b/cli/tests/index.ts @@ -0,0 +1,2 @@ +export { buildTests } from './build-tests.js'; +export { runTests } from './run-tests.js'; diff --git a/cli/tests/run-tests.ts b/cli/tests/run-tests.ts new file mode 100644 index 000000000..aeebfc472 --- /dev/null +++ b/cli/tests/run-tests.ts @@ -0,0 +1,28 @@ +import { runCommand } from '../utils/command.js'; +import Logger from '../utils/logger/logger.js'; +import { TestWithoutBuildingOptions, XcodeBuildOptions } from './types.js'; +import { loadTestWithoutBuildingOptionsFromEnvironment } from './utils.js'; +import { testWithoutBuilding } from './xcode-build.js'; + +export type RunTestsOptions = Omit< + TestWithoutBuildingOptions, + keyof XcodeBuildOptions +>; + +const logger = new Logger('build-tests'); + +export const runTests = async (options: RunTestsOptions) => { + logger.info('Running tests...'); + + const envOptions = await loadTestWithoutBuildingOptionsFromEnvironment(); + logger.debug('Finished loading options from environment'); + + await runCommand(`rm -rf ${options.resultBundlePath}`); + + const fullOptions: TestWithoutBuildingOptions = { + ...envOptions, + ...options, + }; + + await testWithoutBuilding(fullOptions); +}; diff --git a/cli/tests/types.ts b/cli/tests/types.ts new file mode 100644 index 000000000..340f7fd18 --- /dev/null +++ b/cli/tests/types.ts @@ -0,0 +1,54 @@ +/** + * Common to all commands + */ +export type XcodeBuildOptions = { + derivedDataPath: string; + + project: string; + scheme: string; + configuration: string; + destination: string; +}; + +const DEFAULT_XCODE_BUILD_OPTIONS: XcodeBuildOptions = { + configuration: 'Debug', + scheme: 'Parra', + project: 'Parra.xcodeproj', + destination: 'platform=iOS Simulator,name=iPhone 15,OS=17.2', + derivedDataPath: 'build/unit-tests/derivedData', +}; + +export type BuildForTestingEnvOptions = XcodeBuildOptions & { + /** + * Must be a relative path when passed here. It will be converted to an absolute path before being + * passed to `xcodebuild`. Also must be a path to a file containing a private key obtained from ASC. + */ + authenticationKeyPath?: string; + authenticationKeyId?: string; + authenticationKeyIssuerId?: string; +}; + +export type BuildForTestingOptions = BuildForTestingEnvOptions & { + allowProvisioningUpdates: boolean; + allowProvisioningDeviceRegistration: boolean; +}; + +export const DEFAULT_BUILD_FOR_TESTING_ENV_OPTIONS: BuildForTestingEnvOptions = + { + ...DEFAULT_XCODE_BUILD_OPTIONS, + authenticationKeyPath: 'artifacts/asc-key.p8', + }; + +export type TestWithoutBuildingEnvOptions = XcodeBuildOptions; + +export type TestWithoutBuildingOptions = TestWithoutBuildingEnvOptions & { + // -parallel-testing-enabled yes \ + // -parallel-testing-worker-count 2 \ + // -maximum-parallel-testing-workers 4 \ + + enableCodeCoverage?: boolean; + resultBundlePath: string; +}; + +export const DEFAULT_TEST_WITHOUT_BUILDING_ENV_OPTIONS: TestWithoutBuildingEnvOptions = + DEFAULT_XCODE_BUILD_OPTIONS; diff --git a/cli/tests/utils.ts b/cli/tests/utils.ts new file mode 100644 index 000000000..0f1d387bd --- /dev/null +++ b/cli/tests/utils.ts @@ -0,0 +1,145 @@ +import { runThrowingCommand } from '../utils/command.js'; +import { + BuildForTestingEnvOptions, + BuildForTestingOptions, + DEFAULT_BUILD_FOR_TESTING_ENV_OPTIONS, + DEFAULT_TEST_WITHOUT_BUILDING_ENV_OPTIONS, + TestWithoutBuildingEnvOptions, + TestWithoutBuildingOptions, + XcodeBuildOptions, +} from './types.js'; + +export const loadBuildForTestingOptionsFromEnvironment = + async (): Promise => { + const env = process.env; + const defaults = DEFAULT_BUILD_FOR_TESTING_ENV_OPTIONS; + + // If an App Store Connect authentication key is provideded, we need to: + // 1. Ensure that other required environment variables are provided. + // 2. Decode the key, since it is stored in base64 in CircleCI. + // 3. Convert the relative path to an absolute path, since `xcodebuild` requires an absolute path. + let authentication: Pick< + BuildForTestingEnvOptions, + | 'authenticationKeyPath' + | 'authenticationKeyId' + | 'authenticationKeyIssuerId' + > = {}; + const ascApiKey = env.PARRA_ASC_API_KEY; + + if (ascApiKey) { + if (!env.PARRA_ASC_API_KEY_ID) { + throw new Error( + 'PARRA_ASC_API_KEY_ID is required when PARRA_ASC_API_KEY is provided.' + ); + } + + if (!env.PARRA_ASC_API_ISSUER_ID) { + throw new Error( + 'PARRA_ASC_API_ISSUER_ID is required when PARRA_ASC_API_KEY is provided.' + ); + } + + const absoluteAuthenticationKeyPath = `/tmp/workspace/${defaults.authenticationKeyPath}`; + + await runThrowingCommand( + `echo "${ascApiKey}" | base64 --decode > ${absoluteAuthenticationKeyPath}` + ); + + authentication = { + authenticationKeyId: env.PARRA_ASC_API_KEY_ID, + authenticationKeyIssuerId: env.PARRA_ASC_API_ISSUER_ID, + authenticationKeyPath: absoluteAuthenticationKeyPath, + }; + } + + return { + ...loadCommonXcodeBuildOptionsFromEnvironment(), + ...authentication, + }; + }; + +export const loadTestWithoutBuildingOptionsFromEnvironment = + async (): Promise => { + // Tests do not currently load anything non-standard from the environment. + return loadCommonXcodeBuildOptionsFromEnvironment(); + }; + +export const loadCommonXcodeBuildOptionsFromEnvironment = + (): XcodeBuildOptions => { + const env = process.env; + const defaults = DEFAULT_TEST_WITHOUT_BUILDING_ENV_OPTIONS; + + return { + project: env.PARRA_TEST_PROJECT_NAME || defaults.project, + scheme: env.PARRA_TEST_SCHEME_NAME || defaults.scheme, + configuration: env.PARRA_TEST_CONFIGURATION || defaults.configuration, + destination: env.PARRA_TEST_DESTINATION || defaults.destination, + derivedDataPath: + env.PARRA_TEST_DERIVED_DATA_DIRECTORY || defaults.derivedDataPath, + }; + }; + +export const buildWithoutTestingArgStringFromOptions = async ( + options: BuildForTestingOptions +): Promise => { + const { + allowProvisioningUpdates, + allowProvisioningDeviceRegistration, + authenticationKeyPath, + authenticationKeyId, + authenticationKeyIssuerId, + } = options; + + const args = commonArgsFromOptions(options); + + if ( + authenticationKeyPath && + authenticationKeyId && + authenticationKeyIssuerId + ) { + args.push(`-authenticationKeyPath "${authenticationKeyPath}"`); + args.push(`-authenticationKeyID "${authenticationKeyId}"`); + args.push(`-authenticationKeyIssuerID "${authenticationKeyIssuerId}"`); + + // These flags can't be used if the authentication flags are not provided. + if (allowProvisioningUpdates) { + args.push('-allowProvisioningUpdates'); + } + + if (allowProvisioningDeviceRegistration) { + args.push('-allowProvisioningDeviceRegistration'); + } + } + + return args.join(' '); +}; + +export const testWithoutBuildingArgStringFromOptions = async ( + options: TestWithoutBuildingOptions +): Promise => { + const { enableCodeCoverage, resultBundlePath } = options; + const args = commonArgsFromOptions(options); + + if (enableCodeCoverage) { + args.push('-enableCodeCoverage YES'); + } + + if (resultBundlePath) { + args.push(`-resultBundlePath "${resultBundlePath}"`); + } + + return args.join(' '); +}; + +const commonArgsFromOptions = (options: XcodeBuildOptions) => { + const { project, scheme, configuration, destination, derivedDataPath } = + options; + + return [ + `-project "${project}"`, + `-scheme "${scheme}"`, + `-configuration "${configuration}"`, + `-destination "${destination}"`, + `-derivedDataPath "${derivedDataPath}"`, + ]; +}; diff --git a/cli/tests/xcode-build.ts b/cli/tests/xcode-build.ts new file mode 100644 index 000000000..011a2caf4 --- /dev/null +++ b/cli/tests/xcode-build.ts @@ -0,0 +1,68 @@ +import { runCommand } from '../utils/command.js'; +import Logger from '../utils/logger/logger.js'; +import { BuildForTestingOptions, TestWithoutBuildingOptions } from './types.js'; +import { + buildWithoutTestingArgStringFromOptions, + testWithoutBuildingArgStringFromOptions, +} from './utils.js'; + +const logger = new Logger('xcode-build'); +const isCI = !!process.env.CI; + +export const buildForTesting = async ( + options: BuildForTestingOptions +): Promise => { + const { derivedDataPath } = options; + const args = await buildWithoutTestingArgStringFromOptions(options); + + await runXcodeBuildCommand( + 'build-for-testing', + args, + derivedDataPath, + '| tee buildlog' + ); +}; + +export const testWithoutBuilding = async ( + options: TestWithoutBuildingOptions +): Promise => { + const { derivedDataPath } = options; + const args = await testWithoutBuildingArgStringFromOptions(options); + let commandSuffix = 'xcbeautify --junit-report-filename junit-results.xml'; + + if (isCI) { + commandSuffix += ' --is-ci'; + } + + await runXcodeBuildCommand( + 'test-without-building', + args, + derivedDataPath, + `| ${commandSuffix}` + ); +}; + +const runXcodeBuildCommand = async ( + command: string, + args: string, + derivedDataPath: string, + commandSuffix: string = '' +): Promise => { + // Don't print args in CI. They contain sensitive information. + if (isCI) { + logger.debug(`Running command: xcodebuild ${command}`); + } else { + logger.debug(`Running command: xcodebuild ${command} ${args}`); + } + + await runCommand( + ` + set -xo pipefail; + + export CONFIGURATION_BUILD_DIR="${derivedDataPath}"; + # Disable buffering to ensure that logs are printed in real time. + NSUnbufferedIO=YES set -o pipefail \ + && xcodebuild ${command} ${args} ${commandSuffix} + ` + ); +}; diff --git a/cli/types.ts b/cli/types.ts new file mode 100644 index 000000000..26bada8dd --- /dev/null +++ b/cli/types.ts @@ -0,0 +1,7 @@ +import { Command } from 'commander'; +import Logger from './utils/logger/logger.js'; + +/** + * A common interface for all CLI command functions contained within this directory. + */ +export type ParraCliCommandGenerator = (logger: Logger) => Command; diff --git a/cli/utils/command.ts b/cli/utils/command.ts new file mode 100644 index 000000000..dbf1793db --- /dev/null +++ b/cli/utils/command.ts @@ -0,0 +1,86 @@ +import { ChildProcess, exec } from 'child_process'; +import Logger from './logger/logger.js'; +import { CommandOptions, DEFAULT_COMMAND_OPTIONS } from './types.js'; + +const logger = new Logger('Command'); + +export class CommandResult { + constructor( + public readonly stdout: string, + public readonly stderr: string, + public readonly childProcess: ChildProcess + ) {} +} + +const execAsync = async (command: string, options: CommandOptions) => { + return new Promise((resolve, reject) => { + const childProcess = exec(command, options); + + let stdOutOutput = ''; + let stdErrOutput = ''; + + childProcess.stdout.on('data', (data) => { + logger.raw(false, data); + stdOutOutput += data; + }); + + childProcess.stderr.on('data', (data) => { + stdErrOutput += data; + + logger.raw(true, data); + }); + + childProcess.on('close', (code, signal) => { + if (code === null) { + // If you're seeing this, it's possible that the maxBuffer size for exec was exceeded. + reject(new Error(`Command failed with signal ${signal}`)); + + return; + } + + if (code !== 0 || (options.throwForStdErr && !!stdErrOutput)) { + if (code === 0) { + reject( + new Error(`Command failed with stderr output: ${stdErrOutput}`) + ); + } else { + reject(new Error(`Command failed with code ${code}`)); + } + } else { + resolve(new CommandResult(stdOutOutput, stdErrOutput, childProcess)); + } + }); + }); +}; + +/** + * Invoke the provided command and return the stdout and stderr output. + */ +export const runCommand = async ( + command: string, + options: CommandOptions = DEFAULT_COMMAND_OPTIONS +): Promise => { + const { throwForStdErr, ...rest } = options; + + return execAsync(command, { + ...DEFAULT_COMMAND_OPTIONS, + throwForStdErr: !!throwForStdErr, + ...rest, + }); +}; + +/** + * Invoke the provided command and throw an exception if output is piped to stderr. + */ +export const runThrowingCommand = async ( + command: string, + options: CommandOptions = DEFAULT_COMMAND_OPTIONS +): Promise => { + const { stdout } = await runCommand(command, { + ...DEFAULT_COMMAND_OPTIONS, + ...options, + throwForStdErr: true, + }); + + return stdout; +}; diff --git a/cli/utils/logger/logger.ts b/cli/utils/logger/logger.ts new file mode 100644 index 000000000..8c80597f2 --- /dev/null +++ b/cli/utils/logger/logger.ts @@ -0,0 +1,144 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// Any is permissible here since we are just forwarding to console.log/etc which support any. + +import chalk, { ChalkInstance } from 'chalk'; +import { DEFAULT_LOGGER_OPTIONS, LogLevel, LoggerOptions } from './types.js'; + +const DEFAULT_LOG_LEVEL = process.env.CI ? LogLevel.Debug : LogLevel.Info; + +export default class Logger { + constructor( + private readonly name: string, + private readonly options: LoggerOptions = DEFAULT_LOGGER_OPTIONS + ) {} + + static globalLevel: LogLevel = DEFAULT_LOG_LEVEL; + + static setGlobalLogLevel(raw: string) { + this.globalLevel = Logger.levelForSlug(raw); + } + + public success(message: any, ...optionalParams: any[]): void { + this.log(message, LogLevel.Success, console.log, optionalParams); + } + + public debug(message: any, ...optionalParams: any[]): void { + this.log(message, LogLevel.Debug, console.log, optionalParams); + } + + public info(message: any, ...optionalParams: any[]): void { + this.log(message, LogLevel.Info, console.log, optionalParams); + } + + public warn(message: any, ...optionalParams: any[]): void { + this.log(message, LogLevel.Warn, console.warn, optionalParams); + } + + public error(message: any, ...optionalParams: any[]): void { + this.log(message, LogLevel.Error, console.error, optionalParams); + } + + /** + * A special logger function that outputs the message without any formatting, except for coloring + * error logs. This still respects the enabled flag or other settings. This is useful for display live + * output from a child process. + */ + public raw(isError: boolean, message: any, ...optionalParams: any[]): void { + if (this.options.enabled && Logger.globalLevel !== LogLevel.Silent) { + // Stream to stderr if it's an error and we are at least at the warn level. + if (isError && Logger.globalLevel <= LogLevel.Error) { + process.stderr.write( + this.getChalkForLevel(LogLevel.Warn)(message, ...optionalParams) + ); + } + + // Stream all non-error logs to stdout as long as the logger is enabled. These are to + // be considered debug level logs. + if (!isError && Logger.globalLevel < LogLevel.Info) { + process.stdout.write(message, ...optionalParams); + } + } + } + + private log( + message: any, + level: LogLevel, + logFunction: (message: any, ...optionalParams: any[]) => void, + ...optionalParams: any[] + ): void { + const { chalkEnabled, enabled } = this.options; + + if (!enabled || !this.shouldLog(level)) { + return; + } + + const levelChalk = this.getChalkForLevel(level); + const slug = levelChalk(this.slugForLevel(level)); + const fullMessage = [message, ...optionalParams].join('\n').trim(); + + if (chalkEnabled) { + const prefix = `[${this.name.toUpperCase()}][${levelChalk(slug)}]`; + + logFunction(prefix, levelChalk(fullMessage)); + } else { + const prefix = `[${this.name.toUpperCase()}][${slug}]`; + + logFunction(prefix, fullMessage); + } + } + + private getChalkForLevel(level: LogLevel): ChalkInstance { + switch (level) { + case LogLevel.Success: + return chalk.greenBright; + case LogLevel.Debug: + return chalk.white; + case LogLevel.Info: + return chalk.blueBright; + case LogLevel.Warn: + return chalk.yellowBright; + case LogLevel.Error: + return chalk.redBright; + } + } + + private shouldLog(level: LogLevel): boolean { + return level >= Logger.globalLevel; + } + + private slugForLevel(level: LogLevel): string { + switch (level) { + case LogLevel.Success: + return 'SUCCESS 🚀'; + case LogLevel.Debug: + return 'DEBUG'; + case LogLevel.Info: + return 'INFO'; + case LogLevel.Warn: + return 'WARN'; + case LogLevel.Error: + return 'ERROR'; + case LogLevel.Silent: + return 'SILENT'; + } + } + + private static levelForSlug(slug: string): LogLevel { + switch (slug?.toUpperCase()) { + case 'SUCCESS': + return LogLevel.Success; + case 'DEBUG': + return LogLevel.Debug; + case 'INFO': + return LogLevel.Info; + case 'WARN': + return LogLevel.Warn; + case 'ERROR': + return LogLevel.Error; + case 'SILENT': + return LogLevel.Silent; + default: + return DEFAULT_LOG_LEVEL; + } + } +} diff --git a/cli/utils/logger/types.ts b/cli/utils/logger/types.ts new file mode 100644 index 000000000..299f1e724 --- /dev/null +++ b/cli/utils/logger/types.ts @@ -0,0 +1,25 @@ +export enum LogLevel { + Debug = 10, + Info = 20, + // This is weird, but I think I want success logs to basically be the same level as info logs. + Success = 21, + Warn = 30, + Error = 40, + Silent = 100, +} + +/** + * Options used for all logs emitted by the logger instance. + */ +export type LoggerOptions = { + chalkEnabled: boolean; + enabled: boolean; +}; + +/** + * The default values for the options provided to the Logger instance. + */ +export const DEFAULT_LOGGER_OPTIONS: LoggerOptions = { + chalkEnabled: true, + enabled: true, +}; diff --git a/cli/utils/openAppContainer.ts b/cli/utils/openAppContainer.ts new file mode 100644 index 000000000..02ec93a8f --- /dev/null +++ b/cli/utils/openAppContainer.ts @@ -0,0 +1,25 @@ +import { runThrowingCommand } from './command.js'; + +export type OpenAppContainerOptions = { + bundleId: string; +}; + +export const openAppContainer = async (options: OpenAppContainerOptions) => { + const { bundleId } = options; + + if (!bundleId) { + throw new Error('Missing bundleId'); + } + + runThrowingCommand( + `open \`xcrun simctl get_app_container booted ${bundleId} data\` -a Finder` + ); +}; + +export const openAppContainerForDemoApp = async () => { + await openAppContainer({ bundleId: 'com.parra.parra-ios-sdk-demo' }); +}; + +export const openAppContainerForTestRunnerApp = async () => { + await openAppContainer({ bundleId: 'com.parra.ParraTests.Runner' }); +}; diff --git a/cli/utils/types.ts b/cli/utils/types.ts new file mode 100644 index 000000000..2cf8f9b6a --- /dev/null +++ b/cli/utils/types.ts @@ -0,0 +1,14 @@ +import { ExecOptions } from 'child_process'; + +export type CommandOptions = { + encoding: BufferEncoding; + throwForStdErr: boolean; +} & ExecOptions; + +export const DEFAULT_COMMAND_OPTIONS: CommandOptions = { + encoding: 'utf8', + maxBuffer: 1024 * 1024 * 100, // 100 MiB + /// If set, throws if stderr is not empty. Defaults to false. since many commands write + /// to stderr even when they succeed. + throwForStdErr: false, +}; diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 5733869d8..000000000 --- a/package-lock.json +++ /dev/null @@ -1,1275 +0,0 @@ -{ - "name": "parra-ios-sdk", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "parra-ios-sdk", - "bin": { - "parra-release": "scripts/pod-release.js" - }, - "devDependencies": { - "child_process": "^1.0.2", - "util": "^0.12.4", - "yargs": "~>17.1.0" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/child_process": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/child_process/-/child_process-1.0.2.tgz", - "integrity": "sha1-sffn/HPSXn/R1FWtyU4UODAYK1o=", - "dev": true - }, - "node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/define-properties": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", - "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", - "dev": true, - "dependencies": { - "object-keys": "^1.0.12" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/es-abstract": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.1.tgz", - "integrity": "sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "es-to-primitive": "^1.2.1", - "function-bind": "^1.1.1", - "get-intrinsic": "^1.1.1", - "get-symbol-description": "^1.0.0", - "has": "^1.0.3", - "has-symbols": "^1.0.2", - "internal-slot": "^1.0.3", - "is-callable": "^1.2.4", - "is-negative-zero": "^2.0.1", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.1", - "is-string": "^1.0.7", - "is-weakref": "^1.0.1", - "object-inspect": "^1.11.0", - "object-keys": "^1.1.1", - "object.assign": "^4.1.2", - "string.prototype.trimend": "^1.0.4", - "string.prototype.trimstart": "^1.0.4", - "unbox-primitive": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "dev": true, - "dependencies": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/foreach": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz", - "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=", - "dev": true - }, - "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", - "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-symbol-description": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", - "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/has-bigints": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz", - "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", - "dev": true, - "dependencies": { - "has-symbols": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/internal-slot": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", - "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.1.0", - "has": "^1.0.3", - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-arguments": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "dev": true, - "dependencies": { - "has-bigints": "^1.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz", - "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-function": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", - "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", - "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number-object": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.6.tgz", - "integrity": "sha512-bEVOqiRcvo3zO1+G2lVMy+gkkEm9Yh7cDMRusKKu5ZJKPUYSJwICTKZrNKHA2EbSP0Tu0+6B/emsYNHZyn6K8g==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz", - "integrity": "sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", - "dev": true, - "dependencies": { - "has-symbols": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.8.tgz", - "integrity": "sha512-HqH41TNZq2fgtGT8WHVFVJhBVGuY3AnP3Q36K8JKXUxSxRgk/d+7NjmwG2vo2mYmXK8UYZKu0qH8bVP5gEisjA==", - "dev": true, - "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "es-abstract": "^1.18.5", - "foreach": "^2.0.5", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-inspect": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz", - "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", - "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.0", - "define-properties": "^1.1.3", - "has-symbols": "^1.0.1", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz", - "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz", - "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/unbox-primitive": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz", - "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1", - "has-bigints": "^1.0.1", - "has-symbols": "^1.0.2", - "which-boxed-primitive": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/util": { - "version": "0.12.4", - "resolved": "https://registry.npmjs.org/util/-/util-0.12.4.tgz", - "integrity": "sha512-bxZ9qtSlGUWSOy9Qa9Xgk11kSslpuZwaxCg4sNIDj6FLucDab2JxnHwyNTCpHMtK1MjoQiWQ6DiUMZYbSrO+Sw==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "safe-buffer": "^5.1.2", - "which-typed-array": "^1.1.2" - } - }, - "node_modules/which-boxed-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", - "dev": true, - "dependencies": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.7.tgz", - "integrity": "sha512-vjxaB4nfDqwKI0ws7wZpxIlde1XrLX5uB0ZjpfshgmapJMD7jJWhZI+yToJTqaFByF0eNBcYxbjmCzoRP7CfEw==", - "dev": true, - "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "es-abstract": "^1.18.5", - "foreach": "^2.0.5", - "has-tostringtag": "^1.0.0", - "is-typed-array": "^1.1.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs": { - "version": "17.1.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.1.1.tgz", - "integrity": "sha512-c2k48R0PwKIqKhPMWjeiF6y2xY/gPMUlro0sgxqXpbOIohWiLNXWslsootttv7E1e73QPAMQSg5FeySbVcpsPQ==", - "dev": true, - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, - "engines": { - "node": ">=10" - } - } - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "available-typed-arrays": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", - "dev": true - }, - "call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dev": true, - "requires": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" - } - }, - "child_process": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/child_process/-/child_process-1.0.2.tgz", - "integrity": "sha1-sffn/HPSXn/R1FWtyU4UODAYK1o=", - "dev": true - }, - "cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "requires": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "define-properties": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", - "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", - "dev": true, - "requires": { - "object-keys": "^1.0.12" - } - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "es-abstract": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.1.tgz", - "integrity": "sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "es-to-primitive": "^1.2.1", - "function-bind": "^1.1.1", - "get-intrinsic": "^1.1.1", - "get-symbol-description": "^1.0.0", - "has": "^1.0.3", - "has-symbols": "^1.0.2", - "internal-slot": "^1.0.3", - "is-callable": "^1.2.4", - "is-negative-zero": "^2.0.1", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.1", - "is-string": "^1.0.7", - "is-weakref": "^1.0.1", - "object-inspect": "^1.11.0", - "object-keys": "^1.1.1", - "object.assign": "^4.1.2", - "string.prototype.trimend": "^1.0.4", - "string.prototype.trimstart": "^1.0.4", - "unbox-primitive": "^1.0.1" - } - }, - "es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "dev": true, - "requires": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" - } - }, - "escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true - }, - "foreach": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz", - "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=", - "dev": true - }, - "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true - }, - "get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true - }, - "get-intrinsic": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", - "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", - "dev": true, - "requires": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.1" - } - }, - "get-symbol-description": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", - "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" - } - }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "requires": { - "function-bind": "^1.1.1" - } - }, - "has-bigints": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz", - "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==", - "dev": true - }, - "has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true - }, - "has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", - "dev": true, - "requires": { - "has-symbols": "^1.0.2" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "internal-slot": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", - "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==", - "dev": true, - "requires": { - "get-intrinsic": "^1.1.0", - "has": "^1.0.3", - "side-channel": "^1.0.4" - } - }, - "is-arguments": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - } - }, - "is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "dev": true, - "requires": { - "has-bigints": "^1.0.1" - } - }, - "is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - } - }, - "is-callable": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz", - "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==", - "dev": true - }, - "is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", - "dev": true, - "requires": { - "has-tostringtag": "^1.0.0" - } - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "is-generator-function": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", - "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", - "dev": true, - "requires": { - "has-tostringtag": "^1.0.0" - } - }, - "is-negative-zero": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", - "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", - "dev": true - }, - "is-number-object": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.6.tgz", - "integrity": "sha512-bEVOqiRcvo3zO1+G2lVMy+gkkEm9Yh7cDMRusKKu5ZJKPUYSJwICTKZrNKHA2EbSP0Tu0+6B/emsYNHZyn6K8g==", - "dev": true, - "requires": { - "has-tostringtag": "^1.0.0" - } - }, - "is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - } - }, - "is-shared-array-buffer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz", - "integrity": "sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA==", - "dev": true - }, - "is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", - "dev": true, - "requires": { - "has-tostringtag": "^1.0.0" - } - }, - "is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", - "dev": true, - "requires": { - "has-symbols": "^1.0.2" - } - }, - "is-typed-array": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.8.tgz", - "integrity": "sha512-HqH41TNZq2fgtGT8WHVFVJhBVGuY3AnP3Q36K8JKXUxSxRgk/d+7NjmwG2vo2mYmXK8UYZKu0qH8bVP5gEisjA==", - "dev": true, - "requires": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "es-abstract": "^1.18.5", - "foreach": "^2.0.5", - "has-tostringtag": "^1.0.0" - } - }, - "is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.2" - } - }, - "object-inspect": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz", - "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==", - "dev": true - }, - "object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true - }, - "object.assign": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", - "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.0", - "define-properties": "^1.1.3", - "has-symbols": "^1.0.1", - "object-keys": "^1.1.1" - } - }, - "require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", - "dev": true - }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true - }, - "side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dev": true, - "requires": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" - } - }, - "string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - } - }, - "string.prototype.trimend": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz", - "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3" - } - }, - "string.prototype.trimstart": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz", - "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3" - } - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - }, - "unbox-primitive": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz", - "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==", - "dev": true, - "requires": { - "function-bind": "^1.1.1", - "has-bigints": "^1.0.1", - "has-symbols": "^1.0.2", - "which-boxed-primitive": "^1.0.2" - } - }, - "util": { - "version": "0.12.4", - "resolved": "https://registry.npmjs.org/util/-/util-0.12.4.tgz", - "integrity": "sha512-bxZ9qtSlGUWSOy9Qa9Xgk11kSslpuZwaxCg4sNIDj6FLucDab2JxnHwyNTCpHMtK1MjoQiWQ6DiUMZYbSrO+Sw==", - "dev": true, - "requires": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "safe-buffer": "^5.1.2", - "which-typed-array": "^1.1.2" - } - }, - "which-boxed-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", - "dev": true, - "requires": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" - } - }, - "which-typed-array": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.7.tgz", - "integrity": "sha512-vjxaB4nfDqwKI0ws7wZpxIlde1XrLX5uB0ZjpfshgmapJMD7jJWhZI+yToJTqaFByF0eNBcYxbjmCzoRP7CfEw==", - "dev": true, - "requires": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "es-abstract": "^1.18.5", - "foreach": "^2.0.5", - "has-tostringtag": "^1.0.0", - "is-typed-array": "^1.1.7" - } - }, - "wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - } - }, - "y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true - }, - "yargs": { - "version": "17.1.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.1.1.tgz", - "integrity": "sha512-c2k48R0PwKIqKhPMWjeiF6y2xY/gPMUlro0sgxqXpbOIohWiLNXWslsootttv7E1e73QPAMQSg5FeySbVcpsPQ==", - "dev": true, - "requires": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - } - }, - "yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true - } - } -} diff --git a/package.json b/package.json index 82fd73941..95fde122d 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,44 @@ { "name": "parra-ios-sdk", - "scripts": {}, + "version": "0.1.0", + "license": "MIT", + "homepage": "https://github.com/Parra-Inc/parra-ios-sdk", + "repository": { + "type": "git", + "url": "https://github.com/Parra-Inc/parra-ios-sdk.git" + }, + "main": "dist/index.ts", + "type": "module", + "scripts": { + "lint": "eslint . --ext .ts", + "preinstall": "git config diff.lockb.textconv bun && git config diff.lockb.binary true" + }, "bin": { "parra-release": "scripts/pod-release.js" }, "devDependencies": { + "@inquirer/prompts": "^3.3.0", + "@typescript-eslint/eslint-plugin": "^6.17.0", + "@typescript-eslint/parser": "^6.17.0", + "chalk": "^5.3.0", "child_process": "^1.0.2", - "util": "^0.12.4", - "yargs": "~>17.1.0" - } + "commander": "^11.1.0", + "eslint": "^8.56.0", + "eslint-plugin-unused-imports": "^3.0.0", + "prettier": "^3.1.1", + "tsx": "^4.7.0", + "typescript": "^5.3.3", + "util": "^0.12.4" + }, + "engines": { + "node": "~>21.4.0", + "npm": "~>10.2.4" + }, + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "dependencies": {} } diff --git a/scripts/openAppData.sh b/scripts/openAppData.sh deleted file mode 100755 index 11158fa9e..000000000 --- a/scripts/openAppData.sh +++ /dev/null @@ -1,6 +0,0 @@ -#! /bin/sh - -# Opens the Parra demo app data file from the current simulator in Finder. -# Useful for debugging data storage. -open `xcrun simctl get_app_container booted com.parra.parra-ios-sdk-demo data` -a Finder - diff --git a/scripts/pod-release.js b/scripts/pod-release.js deleted file mode 100755 index b085a3616..000000000 --- a/scripts/pod-release.js +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env node - -const util = require('util'); -const yargs = require('yargs/yargs') -const { hideBin } = require('yargs/helpers') -const exec = util.promisify(require('child_process').exec); - -const argv = yargs(hideBin(process.argv)) - .version(false) - .option('tag', { // can't use --version or -v since they interfer with npm's version arg. - alias: 't', - type: 'string', - description: 'The tag/version that you want to release.', - demandOption: true, - }) - .help() - .argv - -const { tag } = argv; -const podSpec = `Parra.podspec`; -const gitTag = `parra-${tag}`; - -(async () => { - try { - console.log((await exec('bundle install')).stdout); - - console.log((await exec(`ruby ./scripts/set-framework-version.rb ${tag}`)).stdout); - - console.log((await exec('pod repo update')).stdout); - - console.log(`[PARRA CLI] Preparing to release version: ${tag} of ${podSpec}`); - - const status = await (await exec('git status -s')).stdout; - if (status.length > 0) { - console.log((await exec(`git add -A && git commit -m "Release v${tag}"`)).stdout); - } - - console.log(await exec(`git tag "${gitTag}"`)); - console.log((await exec('git push')).stdout); - console.log((await exec('git push --tags')).stdout); - - const { stdout, stderr } = await exec( - `PARRA_TAG="${gitTag}" PARRA_VERSION="${tag}" pod trunk push ${podSpec} --allow-warnings` - ); - console.error(stderr); - console.log(stdout); - - console.log(`[PARRA CLI] successfully published release version: ${tag} of ${podSpec}`); - } catch (error) { - console.error(error); - throw error; - } -})(); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..9aa6d1152 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "esModuleInterop": false, + "target": "es2022", + "moduleResolution": "NodeNext", + "sourceMap": true, + "outDir": "dist" + }, + "lib": ["ES2022"] +}